feat(vacay): read-only calendar sharing (#444, #667) (#1637)

* feat(vacay): read-only calendar shares on the server (#444, #667)

* feat(vacay): shared calendars card and ring overlay on desktop

* feat(vacay): share sheet and calendar rings on mobile

* test(vacay): cover the calendar share flows

* docs(vacay): document read-only calendar sharing
This commit is contained in:
Maurice
2026-07-21 13:46:38 +02:00
committed by GitHub
parent 7528706d00
commit 31f6ec1f47
129 changed files with 2593 additions and 97 deletions
@@ -39,6 +39,7 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite',
vacay_share: 'settings.notifyVacayShare',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
+41 -8
View File
@@ -9,10 +9,11 @@ import { Building2, MousePointer2 } from 'lucide-react'
type VacayMode = 'vacation' | 'company'
type HoverTip = { date: string; top: number; left: number }
export type SharedDayMark = { color: string; name: string; fraction?: number; company?: boolean }
export default function VacayCalendar() {
const { t, locale } = useTranslation()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays, sharedCalendars } = useVacayStore()
const [mode, setMode] = useState<VacayMode>('vacation')
// Half-day is a per-person modifier on the vacation action, not a mode: with it
// on, clicking a day logs (or converts) it as a 0.5 day for the selected person.
@@ -59,6 +60,24 @@ export default function VacayCalendar() {
return map
}, [entries])
// Shared read-only calendars (#444/#667) render as colored rings, not fills,
// so they never mix into the members' split logic — merged stays merged,
// shared stays a distinct overlay.
const sharedMap = useMemo(() => {
const map: Record<string, SharedDayMark[]> = {}
sharedCalendars.filter(c => !c.hidden).forEach(cal => {
cal.entries.forEach(e => {
if (!map[e.date]) map[e.date] = []
map[e.date].push({ color: cal.color, name: cal.owner_name, fraction: e.fraction })
})
cal.companyHolidays.forEach(h => {
if (!map[h.date]) map[h.date] = []
map[h.date].push({ color: cal.color, name: cal.owner_name, company: true })
})
})
return map
}, [sharedCalendars])
const blockWeekends = plan?.block_weekends !== false
const weekendDays = useMemo<number[]>(() => (plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]), [plan?.weekend_days])
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
@@ -74,8 +93,9 @@ export default function VacayCalendar() {
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1)
}, [mode, halfDay, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, weekendDays, companyHolidaysEnabled, selectedUserId])
// Only half-day cells report a hover, so the tooltip appears exactly when there's
// a half day to explain. Fixed-positioned at the root so no card clips it.
// Cells with a half day or a shared overlay report a hover, so the tooltip
// appears exactly when there's something to explain. Fixed-positioned at the
// root so no card clips it.
const handleCellHover = useCallback((dateStr: string | null, el: HTMLElement | null) => {
if (!dateStr || !el) { setTip(null); return }
const r = el.getBoundingClientRect()
@@ -84,6 +104,7 @@ export default function VacayCalendar() {
const selectedUser = users.find(u => u.id === selectedUserId)
const tipEntries = tip ? entryMap[tip.date] : undefined
const tipShared = tip ? sharedMap[tip.date] : undefined
const tipDate = tip ? new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'long' }).format(new Date(tip.date + 'T00:00:00')) : ''
return (
@@ -98,6 +119,7 @@ export default function VacayCalendar() {
companyHolidaySet={companyHolidaySet}
companyHolidaysEnabled={companyHolidaysEnabled}
entryMap={entryMap}
sharedMap={sharedMap}
onCellClick={handleCellClick}
onCellHover={handleCellHover}
companyMode={companyMode}
@@ -109,17 +131,18 @@ export default function VacayCalendar() {
))}
</div>
{/* Custom half-day tooltip — who is off on this date and how much. Rendered
fixed at the root (not inside a month card) so backdrop-filter stacking
contexts can't clip or occlude it. */}
{tip && tipEntries && tipEntries.length > 0 && (
{/* Custom day tooltip — who is off on this date and how much (own members
with half days, plus shared read-only calendars). Rendered fixed at the
root (not inside a month card) so backdrop-filter stacking contexts
can't clip or occlude it. */}
{tip && ((tipEntries && tipEntries.length > 0) || (tipShared && tipShared.length > 0)) && (
<div
className="vg-card rounded-xl"
style={{ position: 'fixed', top: tip.top - 9, left: tip.left, transform: 'translate(-50%, -100%)', zIndex: 80, pointerEvents: 'none' }}
>
<div style={{ padding: '8px 11px', minWidth: 132 }}>
<div className="capitalize" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.02em', color: 'var(--vg-ink3)', marginBottom: 5 }}>{tipDate}</div>
{tipEntries.map((e, i) => {
{(tipEntries ?? []).map((e, i) => {
const isHalf = (e.fraction ?? 1) === 0.5
return (
<div key={i} className="flex items-center gap-2" style={{ marginTop: i ? 4 : 0 }}>
@@ -131,6 +154,16 @@ export default function VacayCalendar() {
</div>
)
})}
{/* Shared calendars: ring dot instead of a filled one, like the grid. */}
{(tipShared ?? []).map((m, i) => (
<div key={`s${i}`} className="flex items-center gap-2" style={{ marginTop: (tipEntries?.length || i) ? 4 : 0 }}>
<span className="w-2 h-2 rounded-full shrink-0" style={{ border: `2px solid ${m.color}` }} />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--vg-ink)' }}>{m.name}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 12, fontSize: 11, fontWeight: 700, color: 'var(--vg-ink3)' }}>
{m.company ? t('vacay.companyHoliday') : (m.fraction ?? 1) === 0.5 ? t('vacay.modeHalf') : t('vacay.fullDay')}
</span>
</div>
))}
</div>
</div>
)}
+17 -7
View File
@@ -11,6 +11,8 @@ interface VacayMonthCardProps {
companyHolidaySet: Set<string>
companyHolidaysEnabled?: boolean
entryMap: Record<string, VacayEntry[]>
// Shared read-only calendars per date (#444/#667) — rendered as rings, not fills.
sharedMap?: Record<string, { color: string }[]>
onCellClick: (date: string) => void
onCellHover?: (date: string | null, el: HTMLElement | null) => void
companyMode: boolean
@@ -21,7 +23,7 @@ interface VacayMonthCardProps {
}
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, sharedMap,
onCellClick, onCellHover, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1
}: VacayMonthCardProps) {
const { t, locale } = useTranslation()
@@ -107,6 +109,11 @@ export default function VacayMonthCard({
// never collides with the two-person diagonal split (#552).
const anyHalf = dayEntries.some(e => (e.fraction ?? 1) === 0.5)
// Shared calendars mark the day with an inset ring per person (capped at
// two — the tooltip lists everyone), keeping them visually apart from the
// filled member days.
const sharedColors = [...new Set((sharedMap?.[dateStr] || []).map(m => m.color))].slice(0, 2)
// Cell fill — people win, then company, then holiday (keeps each calendar's own colour).
let background = 'transparent'
if (dayEntries.length === 1) background = fill(dayEntries[0].person_color)
@@ -116,10 +123,13 @@ export default function VacayMonthCard({
// Weekend / settings-blocked days read as inactive: subtle grey fill, like before the facelift.
else if (weekend && blockWeekends) background = 'color-mix(in srgb, var(--vg-ink3) 7%, transparent)'
// Ring — today's inset outline beats the entry drop-shadow.
const boxShadow = isToday
? 'inset 0 0 0 2px var(--vg-ink)'
: hasEntries ? `0 3px 8px -3px ${pc(dayEntries[0].person_color)}` : undefined
// Rings — today's inset outline first, then one ring per shared calendar
// nested inside it; the entry drop-shadow stays as an outer glow.
const shadows: string[] = []
if (isToday) shadows.push('inset 0 0 0 2px var(--vg-ink)')
sharedColors.forEach((c, i) => shadows.push(`inset 0 0 0 ${(isToday ? 2 : 0) + (i + 1) * 2}px ${c}`))
if (!isToday && hasEntries) shadows.push(`0 3px 8px -3px ${pc(dayEntries[0].person_color)}`)
const boxShadow = shadows.length > 0 ? shadows.join(', ') : undefined
let numColor = 'var(--vg-ink2)'
if (hasEntries) numColor = '#fff'
@@ -141,11 +151,11 @@ export default function VacayMonthCard({
onClick={() => onCellClick(dateStr)}
onMouseEnter={e => {
if (!isBlocked && plain) e.currentTarget.style.background = 'var(--vg-surf2)'
if (anyHalf) onCellHover?.(dateStr, e.currentTarget)
if (anyHalf || sharedColors.length > 0) onCellHover?.(dateStr, e.currentTarget)
}}
onMouseLeave={e => {
if (!isBlocked && plain) e.currentTarget.style.background = background
if (anyHalf) onCellHover?.(null, null)
if (anyHalf || sharedColors.length > 0) onCellHover?.(null, null)
}}
>
{/* 3+ people: quadrant overlay at full colour (1 & 2 use the cell background). */}
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useVacayStore } from '../../store/vacayStore'
import { server } from '../../../tests/helpers/msw/server'
import { http, HttpResponse } from 'msw'
import VacaySharedCalendars from './VacaySharedCalendars'
// ── MSW handler helpers ───────────────────────────────────────────────────────
function withShareUsers() {
server.use(
http.get('/api/addons/vacay/shares/available-users', () =>
HttpResponse.json({ users: [{ id: 2, username: 'Bob' }] })
)
)
}
// ── Store seed helpers ────────────────────────────────────────────────────────
function seedShares(overrides: Record<string, unknown> = {}) {
seedStore(useVacayStore, {
outgoingShares: [],
incomingShares: [],
...overrides,
})
}
const incoming = { id: 7, owner_id: 3, username: 'Carol', color: '#ec4899', hidden: false }
// ─────────────────────────────────────────────────────────────────────────────
beforeEach(() => {
resetAllStores()
})
describe('VacaySharedCalendars', () => {
it('FE-COMP-VACAYSHARED-001: Renders empty hint when nothing is shared', () => {
seedShares()
render(<VacaySharedCalendars />)
expect(document.body).toHaveTextContent('Show each other your calendars without merging them.')
})
it('FE-COMP-VACAYSHARED-002: Renders incoming share with view-only badge', () => {
seedShares({ incomingShares: [incoming] })
render(<VacaySharedCalendars />)
expect(document.body).toHaveTextContent('Carol')
expect(document.body).toHaveTextContent('view only')
})
it('FE-COMP-VACAYSHARED-003: Row click hides a visible share via setShareHidden', async () => {
const setShareHiddenMock = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
seedShares({ incomingShares: [incoming], setShareHidden: setShareHiddenMock })
render(<VacaySharedCalendars />)
// Clicking the username bubbles up to the row's onClick toggle
await user.click(screen.getByText('Carol'))
expect(setShareHiddenMock).toHaveBeenCalledWith(7, true)
})
it('FE-COMP-VACAYSHARED-004: Hidden share row toggles back to visible', async () => {
const setShareHiddenMock = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
seedShares({ incomingShares: [{ ...incoming, hidden: true }], setShareHidden: setShareHiddenMock })
render(<VacaySharedCalendars />)
await user.click(screen.getByTitle('Show in calendar'))
expect(setShareHiddenMock).toHaveBeenCalledWith(7, false)
})
it('FE-COMP-VACAYSHARED-005: Remove button on incoming row calls removeShare', async () => {
const removeShareMock = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
seedShares({ incomingShares: [incoming], removeShare: removeShareMock })
render(<VacaySharedCalendars />)
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(removeShareMock).toHaveBeenCalledWith(7)
})
it('FE-COMP-VACAYSHARED-006: Outgoing section lists recipients with stop sharing', async () => {
const removeShareMock = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
seedShares({
outgoingShares: [{ id: 9, user_id: 2, username: 'Bob' }],
removeShare: removeShareMock,
})
render(<VacaySharedCalendars />)
expect(document.body).toHaveTextContent('You share with')
expect(document.body).toHaveTextContent('Bob')
await user.click(screen.getByRole('button', { name: /stop sharing/i }))
expect(removeShareMock).toHaveBeenCalledWith(9)
})
it('FE-COMP-VACAYSHARED-007: Share modal fetches users and shares with the selected one', async () => {
withShareUsers()
const shareWithMock = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
seedShares({ shareWith: shareWithMock })
render(<VacaySharedCalendars />)
// Open the share modal via the header button
await user.click(screen.getByTitle('Share calendar'))
expect(screen.getByRole('heading', { name: 'Share calendar' })).toBeInTheDocument()
// Wait for MSW to respond and the CustomSelect trigger to appear
await waitFor(() =>
expect(screen.getByRole('button', { name: /select user/i })).toBeInTheDocument()
)
// Open dropdown and select Bob
await user.click(screen.getByRole('button', { name: /select user/i }))
await waitFor(() => expect(screen.getByText('Bob')).toBeInTheDocument())
await user.click(screen.getByText('Bob'))
await user.click(screen.getByRole('button', { name: /^share$/i }))
expect(shareWithMock).toHaveBeenCalledWith(2)
})
})
@@ -0,0 +1,170 @@
import ReactDOM from 'react-dom'
import { useState } from 'react'
import { Share2, Eye, EyeOff, Loader2, X } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
import VacayBadge from './VacayBadge'
/**
* Sidebar card for read-only calendar sharing (#444/#667). Deliberately separate
* from the Persons card: Persons is the fusion (merge) feature, this card only
* grants view access. Incoming rows toggle that person's calendar overlay.
*/
export default function VacaySharedCalendars() {
const { t } = useTranslation()
const toast = useToast()
const { outgoingShares, incomingShares, shareWith, removeShare, setShareHidden } = useVacayStore()
const [showShare, setShowShare] = useState(false)
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
const [selectedUser, setSelectedUser] = useState<number | null>(null)
const [sharing, setSharing] = useState(false)
const loadAvailable = async () => {
try {
const data = await apiClient.get('/addons/vacay/shares/available-users').then(r => r.data)
setAvailableUsers(data.users)
} catch { /* */ }
}
const handleShare = async () => {
if (!selectedUser) return
setSharing(true)
try {
await shareWith(selectedUser)
toast.success(t('vacay.shareSent'))
setShowShare(false)
setSelectedUser(null)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))
} finally {
setSharing(false)
}
}
// The optimistic hide and the removals reject on server errors — surface them
// instead of leaving an unhandled rejection behind a silently reverted toggle.
const handleToggleHidden = (id: number, hidden: boolean) => {
setShareHidden(id, hidden).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed'))))
}
const handleRemove = (id: number) => {
removeShare(id).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed'))))
}
const empty = incomingShares.length === 0 && outgoingShares.length === 0
return (
<div className="vg-card rounded-[22px]" style={{ padding: '14px 18px' }}>
<div className="flex items-center justify-between mb-2">
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.14em', color: 'var(--vg-ink3)' }}>{t('vacay.sharedCalendars')}</span>
<button onClick={() => { setShowShare(true); loadAvailable() }}
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
style={{ color: 'var(--vg-ink3)' }}
title={t('vacay.shareCalendar')}>
<Share2 size={14} />
</button>
</div>
{empty && (
<p style={{ fontSize: 12, color: 'var(--vg-ink3)', lineHeight: 1.5 }}>{t('vacay.sharedEmpty')}</p>
)}
{incomingShares.length > 0 && (
<div className="flex flex-col gap-1">
{incomingShares.map(s => (
<div key={s.id}
onClick={() => handleToggleHidden(s.id, !s.hidden)}
className="flex items-center gap-2.5 group transition-colors cursor-pointer"
style={{ padding: '7px 10px', borderRadius: 12, opacity: s.hidden ? 0.55 : 1 }}
title={s.hidden ? t('vacay.showInCalendar') : t('vacay.hideFromCalendar')}>
{/* Ring dot — mirrors how shared days render in the grid (outline, not fill). */}
<span className="w-3 h-3 rounded-full shrink-0" style={{ border: `2.5px solid ${s.color}` }} />
<span className="truncate min-w-0" style={{ fontSize: 13, fontWeight: 600, color: 'var(--vg-ink)' }}>
{s.username}
</span>
<VacayBadge label={t('vacay.viewOnly')} />
<span className="ml-auto flex items-center gap-1">
<button onClick={e => { e.stopPropagation(); handleRemove(s.id) }}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded flex items-center justify-center transition-all"
style={{ color: 'var(--vg-ink3)' }}
title={t('vacay.remove')}>
<X size={12} />
</button>
{s.hidden
? <EyeOff size={14} style={{ color: 'var(--vg-ink3)' }} />
: <Eye size={14} style={{ color: 'var(--vg-ink2)' }} />}
</span>
</div>
))}
</div>
)}
{outgoingShares.length > 0 && (
<div className="flex flex-col gap-1" style={{ marginTop: incomingShares.length > 0 ? 10 : 0 }}>
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--vg-ink3)', padding: '0 10px' }}>
{t('vacay.youShareWith')}
</span>
{outgoingShares.map(s => (
<div key={s.id} className="flex items-center gap-2.5 group"
style={{ padding: '5px 10px', borderRadius: 12 }}>
<Share2 size={12} style={{ color: 'var(--vg-ink3)' }} />
<span className="truncate min-w-0" style={{ fontSize: 13, color: 'var(--vg-ink2)' }}>
{s.username}
</span>
<button onClick={() => handleRemove(s.id)}
className="ml-auto opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all"
style={{ color: 'var(--vg-ink3)' }}>
{t('vacay.stopSharing')}
</button>
</div>
))}
</div>
)}
{/* Share Modal — Portal to body to avoid z-index issues */}
{showShare && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]" style={{ zIndex: 99990, paddingTop: 70 }}
onClick={() => setShowShare(false)}>
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm bg-surface-card"
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5 border-b border-edge-secondary">
<h2 className="text-base font-semibold text-content">{t('vacay.shareCalendar')}</h2>
<button onClick={() => setShowShare(false)} className="p-1.5 rounded-lg transition-colors text-content-faint">
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs text-content-muted">{t('vacay.shareCalendarHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-xs text-center py-4 text-content-faint">{t('vacay.noUsersAvailable')}</p>
) : (
<CustomSelect
value={selectedUser}
onChange={v => setSelectedUser(Number(v))}
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
placeholder={t('vacay.selectUser')}
searchable
/>
)}
<div className="flex gap-3 justify-end pt-2">
<button onClick={() => setShowShare(false)} className="px-4 py-2 text-sm rounded-lg text-content-muted border border-edge">
{t('common.cancel')}
</button>
<button onClick={handleShare} disabled={!selectedUser || sharing}
className="px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40 bg-content text-surface-card">
{sharing && <Loader2 size={13} className="animate-spin" />}
{t('vacay.share')}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
@@ -38,6 +38,7 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite',
vacay_share: 'settings.notifyVacayShare',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
+22 -1
View File
@@ -1,4 +1,4 @@
import { Building2, ChevronLeft, ChevronRight, Eye, Minus, PenLine, Pencil, Plus, Settings2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
import { Building2, ChevronLeft, ChevronRight, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
@@ -8,6 +8,7 @@ import { useMVacay } from './useMVacay'
import MVacayMonth from './MVacayMonth'
import MVacayInviteSheet from './MVacayInviteSheet'
import MVacaySettingsSheet from './MVacaySettingsSheet'
import MVacayShareSheet from './MVacayShareSheet'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
const WEEKDAY_KEYS_MONDAY = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
@@ -46,6 +47,9 @@ export default function MVacay() {
<MIconBtn onClick={() => v.setSheet('invite')} ariaLabel={t('vacay.inviteUser')}>
<UserPlus size={16} strokeWidth={2} className="text-m-muted" />
</MIconBtn>
<MIconBtn onClick={() => v.setSheet('share')} ariaLabel={t('vacay.sharedCalendars')}>
<Share2 size={16} strokeWidth={2} className="text-m-muted" />
</MIconBtn>
<span className="flex flex-1 items-center justify-between rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] p-1 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]">
<button type="button" onClick={v.prevYear} aria-label={t('mobileVacay.prevYear')} className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full">
<ChevronLeft size={17} strokeWidth={2.2} />
@@ -135,6 +139,22 @@ export default function MVacay() {
</button>
)
})}
{/* Shared read-only calendars: tap toggles that person's ring overlay. */}
{v.incomingShares.map(s => (
<button
key={`share-${s.id}`}
type="button"
onClick={() => v.toggleShareHidden(s.id, !s.hidden)}
aria-pressed={!s.hidden}
className="box-border inline-flex min-w-0 flex-[1_1_calc(25%-6px)] items-center justify-center gap-1 rounded-full px-1 py-1 font-geist text-[0.625rem] font-bold"
style={s.hidden
? { background: 'var(--m-sheetop)', border: '1px solid var(--m-rowbr)', color: 'var(--m-faint)', opacity: 0.7 }
: { background: `${s.color}1f`, border: `1px solid ${s.color}`, color: 'var(--m-ink)' }}
>
<span className="h-[9px] w-[9px] flex-none rounded-[3px]" style={{ border: `2px solid ${s.color}` }} />
<span className="truncate">{s.username}</span>
</button>
))}
{v.companyHolidaysEnabled && (
<LegendChip color="#F5D9A6" label={t('mobileVacay.companyLegend')} />
)}
@@ -280,6 +300,7 @@ export default function MVacay() {
{/* Sheets */}
<MVacayInviteSheet open={v.sheet === 'invite'} onClose={() => v.setSheet(null)} />
<MVacaySettingsSheet open={v.sheet === 'settings'} onClose={() => v.setSheet(null)} />
<MVacayShareSheet open={v.sheet === 'share'} onClose={() => v.setSheet(null)} />
<MVacayIncomingInvite
invites={v.incomingInvites}
onAccept={v.acceptInvite}
@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react'
import { ChevronDown, Eye, EyeOff, Loader2, Share2, X } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import { useVacayStore } from '../../../store/vacayStore'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import { getApiErrorMessage } from '../../../types'
import apiClient from '../../../api/client'
interface MVacayShareSheetProps {
open: boolean
onClose: () => void
}
/**
* Read-only calendar sharing sheet (#444/#667): share your own calendar with
* another TREK user (view only, no fusion), see who shares with you (with a
* per-person overlay eye toggle) and stop shares in both directions.
*/
export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProps) {
const { t } = useTranslation()
const toast = useToast()
const { incomingShares, outgoingShares, shareWith, removeShare, setShareHidden } = useVacayStore()
const [available, setAvailable] = useState<{ id: number; username: string }[]>([])
const [selected, setSelected] = useState<number | null>(null)
const [pickerOpen, setPickerOpen] = useState(false)
const [sending, setSending] = useState(false)
const showError = (err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))
useEffect(() => {
if (!open) return
setSelected(null)
setPickerOpen(false)
apiClient.get('/addons/vacay/shares/available-users')
.then(r => setAvailable(r.data.users))
.catch(() => setAvailable([]))
}, [open])
const selectedUser = available.find(u => u.id === selected)
const handleShare = async () => {
if (!selected) return
setSending(true)
try {
await shareWith(selected)
toast.success(t('vacay.shareSent'))
setSelected(null)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('vacay.shareFailed')))
} finally {
setSending(false)
}
}
return (
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('vacay.sharedCalendars')}>
<div className="px-[18px] pb-[18px] pt-4">
<div className="flex items-center gap-3">
<div className="flex-1 text-[1.0625rem] font-bold">{t('vacay.sharedCalendars')}</div>
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
<div className="mb-3 mt-2 font-geist text-[0.75rem] leading-normal text-m-muted">
{t('vacay.shareCalendarHint')}
</div>
{available.length === 0 ? (
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-3 text-center font-geist text-[0.75rem] text-m-faint">
{t('vacay.noUsersAvailable')}
</div>
) : (
<>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPickerOpen(o => !o)}
className="flex min-w-0 flex-1 items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-3 text-[0.8125rem] font-semibold"
>
<span className={`min-w-0 flex-1 truncate text-left ${selectedUser ? '' : 'text-m-muted'}`}>
{selectedUser ? selectedUser.username : t('vacay.selectUser')}
</span>
<ChevronDown size={14} strokeWidth={2} className="flex-none text-m-faint" />
</button>
<button
type="button"
onClick={handleShare}
disabled={!selected || sending}
className="flex flex-none items-center gap-[6px] rounded-full bg-m-act px-4 py-[10px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
>
{sending ? <Loader2 size={13} className="animate-spin" /> : <Share2 size={13} strokeWidth={2.2} />}
{t('vacay.share')}
</button>
</div>
{pickerOpen && (
<div className="mt-[6px] max-h-[180px] overflow-y-auto rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-[6px]">
{available.map(u => (
<button
key={u.id}
type="button"
onClick={() => { setSelected(u.id); setPickerOpen(false) }}
className={`flex w-full items-center gap-[9px] rounded-[10px] px-[10px] py-[9px] text-left text-[0.8125rem] font-semibold ${
u.id === selected ? 'bg-[color:var(--m-ic)]' : ''
}`}
>
<span className="min-w-0 flex-1 truncate">{u.username}</span>
</button>
))}
</div>
)}
</>
)}
{incomingShares.length > 0 && (
<div className="mt-3 flex flex-col gap-[6px]">
<div className="px-1 font-geist text-[0.625rem] font-bold uppercase tracking-[.06em] text-m-faint">
{t('vacay.sharedWithYou')}
</div>
{incomingShares.map(s => (
<div key={s.id} className="flex items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-[14px] py-[10px]">
<span className="h-[11px] w-[11px] flex-none rounded-full" style={{ border: `2.5px solid ${s.color}` }} />
<span className={`min-w-0 flex-1 truncate text-[0.8125rem] font-semibold ${s.hidden ? 'text-m-faint' : ''}`}>
{s.username}
</span>
<button
type="button"
onClick={() => setShareHidden(s.id, !s.hidden).catch(showError)}
aria-label={s.hidden ? t('vacay.showInCalendar') : t('vacay.hideFromCalendar')}
className="flex h-[28px] w-[28px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)]"
>
{s.hidden
? <EyeOff size={13} strokeWidth={2.2} className="text-m-faint" />
: <Eye size={13} strokeWidth={2.2} className="text-m-muted" />}
</button>
<button
type="button"
onClick={() => removeShare(s.id).catch(showError)}
className="flex-none rounded-full px-[8px] py-1 font-geist text-[0.6875rem] font-semibold text-m-muted"
>
{t('vacay.remove')}
</button>
</div>
))}
</div>
)}
{outgoingShares.length > 0 && (
<div className="mt-3 flex flex-col gap-[6px]">
<div className="px-1 font-geist text-[0.625rem] font-bold uppercase tracking-[.06em] text-m-faint">
{t('vacay.youShareWith')}
</div>
{outgoingShares.map(s => (
<div key={s.id} className="flex items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-[14px] py-[10px]">
<Share2 size={13} strokeWidth={2} className="flex-none text-m-faint" />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{s.username}</span>
<button
type="button"
onClick={() => removeShare(s.id).catch(showError)}
className="flex-none rounded-full px-[10px] py-1 font-geist text-[0.6875rem] font-semibold text-m-muted"
>
{t('vacay.stopSharing')}
</button>
</div>
))}
</div>
)}
</div>
</MSheet>
)
}
+29 -5
View File
@@ -4,14 +4,15 @@ import { useVacay } from '../../../pages/vacay/useVacay'
import { useVacayStore } from '../../../store/vacayStore'
import { useAuthStore } from '../../../store/authStore'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import { tripsApi } from '../../../api/client'
import { isWeekend } from '../../../components/Vacay/holidays'
import { FALLBACK_PERSON_COLOR, localDateStr, type DayVisualContext } from './vacayDayModel'
import type { Trip } from '../../../types'
import { getApiErrorMessage, type Trip } from '../../../types'
export type MVacayView = 'grid' | 'edit'
export type MVacayMode = 'vacation' | 'company'
export type MVacaySheet = 'invite' | 'settings' | null
export type MVacaySheet = 'invite' | 'settings' | 'share' | null
/**
* Screen state of the mobile Vacay experience. Data loading, WebSocket sync
@@ -20,7 +21,8 @@ export type MVacaySheet = 'invite' | 'settings' | null
* mode, sheets) plus the derived per-day render context.
*/
export function useMVacay() {
const { locale } = useTranslation()
const { t, locale } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const {
years, selectedYear, setSelectedYear, loading,
@@ -31,6 +33,7 @@ export function useMVacay() {
entries, companyHolidays, stats, users, holidays,
selectedUserId, setSelectedUserId, isFused,
toggleEntry, toggleCompanyHoliday, updateVacationDays,
incomingShares, sharedCalendars, setShareHidden,
} = useVacayStore()
const currentUser = useAuthStore(s => s.user)
@@ -95,9 +98,23 @@ export function useMVacay() {
return localDateStr(d.getFullYear(), d.getMonth(), d.getDate())
}, [])
// Shared read-only calendars (#444/#667) overlay as rings; hidden ones stay out.
const sharedMap = useMemo(() => {
const map: Record<string, { color: string }[]> = {}
sharedCalendars.filter(c => !c.hidden).forEach(cal => {
const push = (date: string) => {
if (!map[date]) map[date] = []
map[date].push({ color: cal.color })
}
cal.entries.forEach(e => push(e.date))
cal.companyHolidays.forEach(h => push(h.date))
})
return map
}, [sharedCalendars])
const dayCtx = useMemo<DayVisualContext>(() => ({
todayStr, entryMap, companyHolidaySet, companyHolidaysEnabled, holidays, weekendDays,
}), [todayStr, entryMap, companyHolidaySet, companyHolidaysEnabled, holidays, weekendDays])
todayStr, entryMap, companyHolidaySet, companyHolidaysEnabled, holidays, weekendDays, sharedMap,
}), [todayStr, entryMap, companyHolidaySet, companyHolidaysEnabled, holidays, weekendDays, sharedMap])
const monthNamesShort = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: 'short' })
@@ -113,6 +130,12 @@ export function useMVacay() {
const selectedColor = selectedUser?.color || FALLBACK_PERSON_COLOR
const selectedStat = stats.find(s => s.user_id === selectedUserId)
// Shared-chip tap: the optimistic hide toggle rolls back on server errors —
// tell the user instead of letting the chip snap back silently.
const toggleShareHidden = useCallback((shareId: number, hidden: boolean) => {
setShareHidden(shareId, hidden).catch((err: unknown) => toast.error(getApiErrorMessage(err, t('vacay.shareFailed'))))
}, [setShareHidden, toast, t])
// Fusion: logging for each other — any fused member is selectable.
const selectPerson = useCallback((id: number) => {
if (isFused || id === currentUser?.id) {
@@ -174,6 +197,7 @@ export function useMVacay() {
loading, plan, selectedYear,
users, isFused, currentUser,
incomingInvites, acceptInvite, declineInvite,
incomingShares, toggleShareHidden,
view, month, mode, halfDay, setHalfDay, sheet, setSheet, setMode, setMonth,
tripDates, tripDotColor,
blockWeekends, companyHolidaysEnabled, holidaysEnabled, weekStart, weekendDays,
@@ -19,6 +19,9 @@ export interface DayVisualContext {
companyHolidaysEnabled: boolean
holidays: HolidaysMap
weekendDays: number[]
// Shared read-only calendars per date (#444/#667) — drawn as inset rings on
// top of whatever the cell shows, never as fills.
sharedMap?: Record<string, { color: string }[]>
}
function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
@@ -80,7 +83,7 @@ export function splitBackground(colors: string[]): string {
* plain. Logged and company cells keep hard dark inks — the pastels are
* theme-independent surfaces.
*/
export function dayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext): DayVisual {
function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext): DayVisual {
if (dateStr === ctx.todayStr) {
return { background: 'transparent', numColor: 'var(--m-ink)', boxShadow: 'inset 0 0 0 1.5px var(--m-ink)' }
}
@@ -107,6 +110,20 @@ export function dayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualCont
return { background: 'transparent', numColor: 'var(--m-muted)' }
}
export function dayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext): DayVisual {
const visual = baseDayVisual(dateStr, dayOfWeek, ctx)
// Shared calendars (#444/#667) draw inset rings over the base cell — capped at
// two so tiny mini-grid cells stay readable. Nested inside the today ring.
const rings = [...new Set((ctx.sharedMap?.[dateStr] || []).map(m => m.color))].slice(0, 2)
if (rings.length > 0) {
const shadows = visual.boxShadow ? [visual.boxShadow] : []
const base = visual.boxShadow ? 1.5 : 0
rings.forEach((c, i) => shadows.push(`inset 0 0 0 ${base + (i + 1) * 1.5}px ${c}`))
visual.boxShadow = shadows.join(', ')
}
return visual
}
/** Empty leading cells before the 1st, honoring the configured week start. */
export function monthLead(year: number, month: number, weekStart: number): number {
return (new Date(year, month, 1).getDay() - weekStart + 7) % 7
+32
View File
@@ -30,6 +30,10 @@ vi.mock('../components/Vacay/VacaySettings', () => ({
),
}));
vi.mock('../components/Vacay/VacaySharedCalendars', () => ({
default: () => <div data-testid="vacay-shared-calendars" />,
}));
vi.mock('../components/Layout/Navbar', () => ({
default: () => <nav data-testid="navbar" />,
}));
@@ -45,11 +49,14 @@ const makeVacayState = (overrides = {}) => ({
loading: false,
incomingInvites: [] as any[],
plan: null,
sharedCalendars: [] as any[],
loadAll: vi.fn().mockResolvedValue(undefined),
loadPlan: vi.fn().mockResolvedValue(undefined),
loadEntries: vi.fn().mockResolvedValue(undefined),
loadStats: vi.fn().mockResolvedValue(undefined),
loadHolidays: vi.fn().mockResolvedValue(undefined),
loadShares: vi.fn().mockResolvedValue(undefined),
loadSharedCalendars: vi.fn().mockResolvedValue(undefined),
setSelectedYear: vi.fn(),
addYear: vi.fn(),
removeYear: vi.fn().mockResolvedValue(undefined),
@@ -363,4 +370,29 @@ describe('VacayPage', () => {
expect(mockRemoveYear).toHaveBeenCalled();
});
});
// FE-PAGE-VACAY-024: Shared calendars card renders in the sidebar
it('renders VacaySharedCalendars', async () => {
render(<VacayPage />);
await waitFor(() => {
expect(screen.getByTestId('vacay-shared-calendars')).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-025: WebSocket vacay:share reloads shares and shared calendars
it('handles vacay:share WebSocket message', () => {
const mockLoadShares = vi.fn().mockResolvedValue(undefined);
const mockLoadSharedCalendars = vi.fn().mockResolvedValue(undefined);
const addListenerMock = websocket.addListener as ReturnType<typeof vi.fn>;
seedStore(useVacayStore, makeVacayState({
loadShares: mockLoadShares,
loadSharedCalendars: mockLoadSharedCalendars,
}) as any);
render(<VacayPage />);
const handler = addListenerMock.mock.calls[0][0];
mockLoadSharedCalendars.mockClear();
handler({ type: 'vacay:share' });
expect(mockLoadShares).toHaveBeenCalled();
expect(mockLoadSharedCalendars).toHaveBeenCalledWith(2025);
});
});
+13 -4
View File
@@ -4,6 +4,7 @@ import { useTranslation } from '../i18n'
import PageShell from '../components/Layout/PageShell'
import VacayCalendar from '../components/Vacay/VacayCalendar'
import VacayPersons from '../components/Vacay/VacayPersons'
import VacaySharedCalendars from '../components/Vacay/VacaySharedCalendars'
import VacayStats from '../components/Vacay/VacayStats'
import VacaySettings from '../components/Vacay/VacaySettings'
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
@@ -22,12 +23,14 @@ function VacayPageDesktop(): React.ReactElement {
// Page = wiring container: vacay store, live sync + UI state live in the hook.
const {
years, selectedYear, setSelectedYear, removeYear, loading,
incomingInvites, acceptInvite, declineInvite, plan,
incomingInvites, acceptInvite, declineInvite, plan, sharedCalendars,
showSettings, setShowSettings, deleteYear, setDeleteYear,
showMobileSidebar, setShowMobileSidebar,
handleAddNextYear, handleAddPrevYear,
} = useVacay()
const hasVisibleShared = sharedCalendars.some(c => !c.hidden)
if (loading) {
return (
<PageShell background="var(--vg-bg)" contentClassName="flex items-center justify-center" contentStyle={{ minHeight: 'calc(100vh - var(--nav-h))' }}>
@@ -88,8 +91,10 @@ function VacayPageDesktop(): React.ReactElement {
<VacayPersons />
<VacaySharedCalendars />
{/* Legend */}
{(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends) && (
{(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends || hasVisibleShared) && (
<div className="vg-card rounded-[22px]" style={{ padding: '14px 18px' }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.14em', color: 'var(--vg-ink3)' }}>{t('vacay.legend')}</span>
<div className="mt-3 flex flex-wrap gap-x-3.5 gap-y-2.5">
@@ -101,6 +106,7 @@ function VacayPageDesktop(): React.ReactElement {
))}
{plan?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
{hasVisibleShared && <LegendItem ring label={t('vacay.sharedLegend')} />}
</div>
</div>
)}
@@ -258,10 +264,13 @@ function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: num
)
}
function LegendItem({ color, label }: { color: string; label: string }): React.ReactElement {
function LegendItem({ color, label, ring }: { color?: string; label: string; ring?: boolean }): React.ReactElement {
return (
<span className="inline-flex items-center gap-[7px]">
<span style={{ width: 18, height: 12, borderRadius: 4, flex: 'none', background: color }} />
{/* Shared calendars render as rings in the grid, so the legend swatch does too. */}
{ring
? <span style={{ width: 18, height: 12, borderRadius: 4, flex: 'none', border: '2px solid var(--vg-ink2)' }} />
: <span style={{ width: 18, height: 12, borderRadius: 4, flex: 'none', background: color }} />}
<span style={{ fontSize: 12, color: 'var(--vg-ink2)' }}>{label}</span>
</span>
)
+7 -3
View File
@@ -10,7 +10,7 @@ import { addListener, removeListener } from '../../api/websocket'
* Behaviour is identical to the previous in-component logic.
*/
export function useVacay() {
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loadShares, loadSharedCalendars, loading, incomingInvites, acceptInvite, declineInvite, plan, sharedCalendars } = useVacayStore()
const [showSettings, setShowSettings] = useState<boolean>(false)
const [deleteYear, setDeleteYear] = useState<number | null>(null)
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(false)
@@ -28,6 +28,10 @@ export function useVacay() {
if (msg.type === 'vacay:invite' || msg.type === 'vacay:accepted' || msg.type === 'vacay:declined' || msg.type === 'vacay:cancelled' || msg.type === 'vacay:dissolved') {
loadAll()
}
if (msg.type === 'vacay:share' || msg.type === 'vacay:share-removed' || msg.type === 'vacay:shared-update') {
loadShares()
loadSharedCalendars(selectedYear)
}
}, [selectedYear])
useEffect(() => {
@@ -35,7 +39,7 @@ export function useVacay() {
return () => removeListener(handleWsMessage)
}, [handleWsMessage])
useEffect(() => {
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear); loadSharedCalendars(selectedYear) }
}, [selectedYear])
const handleAddNextYear = () => {
@@ -50,7 +54,7 @@ export function useVacay() {
return {
years, selectedYear, setSelectedYear, removeYear, loading,
incomingInvites, acceptInvite, declineInvite, plan,
incomingInvites, acceptInvite, declineInvite, plan, sharedCalendars,
showSettings, setShowSettings, deleteYear, setDeleteYear,
showMobileSidebar, setShowMobileSidebar,
handleAddNextYear, handleAddPrevYear,
+68 -2
View File
@@ -2,11 +2,14 @@ import { create } from 'zustand'
import apiClient from '../api/client'
import { useAuthStore } from './authStore'
import type { AxiosResponse } from 'axios'
import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types'
import type {
VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar,
VacayShareOutgoing, VacayShareIncoming, SharedVacayCalendar,
} from '../types'
import type {
VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest,
VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest,
VacayUpdateStatsRequest,
VacayUpdateStatsRequest, VacayShareRequest, VacayShareUpdateRequest,
} from '@trek/shared'
const ax = apiClient
@@ -74,6 +77,12 @@ interface VacayApi {
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
deleteHolidayCalendar: (id: number) => Promise<unknown>
getShares: () => Promise<{ outgoing: VacayShareOutgoing[]; incoming: VacayShareIncoming[] }>
share: (userId: number) => Promise<unknown>
removeShare: (shareId: number) => Promise<unknown>
updateShare: (shareId: number, hidden: boolean) => Promise<unknown>
shareAvailableUsers: () => Promise<{ users: VacayUser[] }>
getSharedCalendars: (year: number) => Promise<{ calendars: SharedVacayCalendar[] }>
}
const api: VacayApi = {
@@ -99,6 +108,12 @@ const api: VacayApi = {
addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data),
updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data),
deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data),
getShares: () => ax.get('/addons/vacay/shares').then((r: AxiosResponse) => r.data),
share: (userId) => ax.post('/addons/vacay/shares', { user_id: userId } satisfies VacayShareRequest).then((r: AxiosResponse) => r.data),
removeShare: (shareId) => ax.delete(`/addons/vacay/shares/${shareId}`).then((r: AxiosResponse) => r.data),
updateShare: (shareId, hidden) => ax.put(`/addons/vacay/shares/${shareId}`, { hidden } satisfies VacayShareUpdateRequest).then((r: AxiosResponse) => r.data),
shareAvailableUsers: () => ax.get('/addons/vacay/shares/available-users').then((r: AxiosResponse) => r.data),
getSharedCalendars: (year) => ax.get(`/addons/vacay/shares/calendars/${year}`).then((r: AxiosResponse) => r.data),
}
interface VacayState {
@@ -116,6 +131,9 @@ interface VacayState {
selectedUserId: number | null
holidays: HolidaysMap
loading: boolean
outgoingShares: VacayShareOutgoing[]
incomingShares: VacayShareIncoming[]
sharedCalendars: SharedVacayCalendar[]
setSelectedYear: (year: number) => void
setSelectedUserId: (id: number | null) => void
@@ -139,6 +157,11 @@ interface VacayState {
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<void>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<void>
deleteHolidayCalendar: (id: number) => Promise<void>
loadShares: () => Promise<void>
loadSharedCalendars: (year?: number) => Promise<void>
shareWith: (userId: number) => Promise<void>
removeShare: (shareId: number) => Promise<void>
setShareHidden: (shareId: number, hidden: boolean) => Promise<void>
loadAll: () => Promise<void>
}
@@ -157,6 +180,9 @@ export const useVacayStore = create<VacayState>((set, get) => ({
selectedUserId: null,
holidays: {},
loading: false,
outgoingShares: [],
incomingShares: [],
sharedCalendars: [],
setSelectedYear: (year: number) => set({ selectedYear: year }),
setSelectedUserId: (id: number | null) => set({ selectedUserId: id }),
@@ -347,6 +373,44 @@ export const useVacayStore = create<VacayState>((set, get) => ({
await get().loadHolidays()
},
loadShares: async () => {
const data = await api.getShares()
set({ outgoingShares: data.outgoing, incomingShares: data.incoming })
},
loadSharedCalendars: async (year?: number) => {
const y = year || get().selectedYear
const data = await api.getSharedCalendars(y)
set({ sharedCalendars: data.calendars })
},
shareWith: async (userId: number) => {
await api.share(userId)
await get().loadShares()
},
removeShare: async (shareId: number) => {
await api.removeShare(shareId)
await get().loadShares()
await get().loadSharedCalendars()
},
setShareHidden: async (shareId: number, hidden: boolean) => {
// Optimistic: the eye toggle should feel instant; reconcile via refetch.
const prevIncoming = get().incomingShares
const prevCalendars = get().sharedCalendars
set({
incomingShares: prevIncoming.map(s => (s.id === shareId ? { ...s, hidden } : s)),
sharedCalendars: prevCalendars.map(c => (c.share_id === shareId ? { ...c, hidden } : c)),
})
try {
await api.updateShare(shareId, hidden)
} catch (e) {
set({ incomingShares: prevIncoming, sharedCalendars: prevCalendars })
throw e
}
},
loadAll: async () => {
set({ loading: true })
try {
@@ -356,6 +420,8 @@ export const useVacayStore = create<VacayState>((set, get) => ({
await get().loadEntries(year)
await get().loadStats(year)
await get().loadHolidays(year)
await get().loadShares()
await get().loadSharedCalendars(year)
} finally {
set({ loading: false })
}
+25
View File
@@ -330,6 +330,31 @@ export interface HolidaysMap {
[date: string]: HolidayInfo
}
// Read-only calendar shares (#444/#667)
export interface VacayShareOutgoing {
id: number
user_id: number
username: string
}
export interface VacayShareIncoming {
id: number
owner_id: number
username: string
color: string
hidden: boolean
}
export interface SharedVacayCalendar {
share_id: number
owner_id: number
owner_name: string
color: string
hidden: boolean
entries: { date: string; fraction?: number }[]
companyHolidays: { date: string; note?: string }[]
}
// API error shape from axios
export interface ApiError {
response?: {
@@ -124,4 +124,28 @@ export const vacayHandlers = [
http.delete('/api/addons/vacay/plan/holiday-calendars/:id', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/addons/vacay/shares', () => {
return HttpResponse.json({ outgoing: [], incoming: [] });
}),
http.post('/api/addons/vacay/shares', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/addons/vacay/shares/available-users', () => {
return HttpResponse.json({ users: [] });
}),
http.get('/api/addons/vacay/shares/calendars/:year', () => {
return HttpResponse.json({ calendars: [] });
}),
http.put('/api/addons/vacay/shares/:id', () => {
return HttpResponse.json({ success: true });
}),
http.delete('/api/addons/vacay/shares/:id', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -85,4 +85,55 @@ describe('vacayDayModel', () => {
expect(monthLead(2026, 6, 1)).toBe(2);
expect(monthLead(2026, 6, 0)).toBe(3);
});
it('FE-MOB-VACAY-003: shared calendars draw inset rings on top of the base cell', () => {
// Single shared calendar: one 1.5px ring, base cell untouched otherwise.
const one = ctx({
todayStr: '2026-01-01',
sharedMap: { '2026-07-15': [{ color: '#EC4899' }] },
});
expect(dayVisual('2026-07-15', 3, one)).toEqual({
background: 'transparent',
numColor: 'var(--m-muted)',
boxShadow: 'inset 0 0 0 1.5px #EC4899',
});
// Two calendars: rings step outward in 1.5px increments.
const two = ctx({
todayStr: '2026-01-01',
sharedMap: { '2026-07-15': [{ color: '#EC4899' }, { color: '#2FA9A0' }] },
});
expect(dayVisual('2026-07-15', 3, two).boxShadow).toBe(
'inset 0 0 0 1.5px #EC4899, inset 0 0 0 3px #2FA9A0',
);
// A third calendar is dropped — capped at two so mini cells stay readable.
const three = ctx({
todayStr: '2026-01-01',
sharedMap: { '2026-07-15': [{ color: '#EC4899' }, { color: '#2FA9A0' }, { color: '#F59E0B' }] },
});
expect(dayVisual('2026-07-15', 3, three).boxShadow).toBe(
'inset 0 0 0 1.5px #EC4899, inset 0 0 0 3px #2FA9A0',
);
// On today the shared ring nests inside the today ring.
const today = ctx({ sharedMap: { '2026-07-15': [{ color: '#EC4899' }] } });
expect(dayVisual('2026-07-15', 3, today).boxShadow).toBe(
'inset 0 0 0 1.5px var(--m-ink), inset 0 0 0 3px #EC4899',
);
// No sharedMap (or no marks for the date): visuals stay exactly as before.
expect(dayVisual('2026-07-16', 4, ctx({ todayStr: '2026-01-01' }))).toEqual({
background: 'transparent',
numColor: 'var(--m-muted)',
});
const otherDate = ctx({
todayStr: '2026-01-01',
sharedMap: { '2026-07-20': [{ color: '#EC4899' }] },
});
expect(dayVisual('2026-07-16', 4, otherDate)).toEqual({
background: 'transparent',
numColor: 'var(--m-muted)',
});
});
});
+136
View File
@@ -401,4 +401,140 @@ describe('vacayStore', () => {
expect(holidays['2025-12-25']).toBeDefined();
});
});
describe('FE-STORE-VACAY-022: loadShares()', () => {
it('stores outgoing and incoming shares', async () => {
server.use(
http.get('/api/addons/vacay/shares', () =>
HttpResponse.json({
outgoing: [{ id: 1, user_id: 2, username: 'Bob' }],
incoming: [{ id: 2, owner_id: 3, username: 'Carol', color: '#ec4899', hidden: false }],
})
)
);
await useVacayStore.getState().loadShares();
const state = useVacayStore.getState();
expect(state.outgoingShares.length).toBe(1);
expect(state.outgoingShares[0].username).toBe('Bob');
expect(state.incomingShares.length).toBe(1);
expect(state.incomingShares[0].hidden).toBe(false);
});
});
describe('FE-STORE-VACAY-023: loadSharedCalendars() uses selectedYear when no year arg', () => {
it('requests the selected year and stores the calendars', async () => {
useVacayStore.setState({ selectedYear: 2025 });
let requestedYear: string | undefined;
server.use(
http.get('/api/addons/vacay/shares/calendars/:year', ({ params }) => {
requestedYear = params.year as string;
return HttpResponse.json({
calendars: [{
share_id: 2,
owner_id: 3,
owner_name: 'Carol',
color: '#ec4899',
hidden: false,
entries: [{ date: '2025-06-15', fraction: 1 }],
companyHolidays: [],
}],
});
})
);
await useVacayStore.getState().loadSharedCalendars();
const state = useVacayStore.getState();
expect(requestedYear).toBe('2025');
expect(state.sharedCalendars.length).toBe(1);
expect(state.sharedCalendars[0].owner_name).toBe('Carol');
});
});
describe('FE-STORE-VACAY-024: shareWith()', () => {
it('posts the user id and reloads shares', async () => {
let postedUserId: number | undefined;
server.use(
http.post('/api/addons/vacay/shares', async ({ request }) => {
const body = await request.json() as { user_id: number };
postedUserId = body.user_id;
return HttpResponse.json({ success: true });
}),
http.get('/api/addons/vacay/shares', () =>
HttpResponse.json({
outgoing: [{ id: 1, user_id: 5, username: 'Eve' }],
incoming: [],
})
)
);
await useVacayStore.getState().shareWith(5);
const state = useVacayStore.getState();
expect(postedUserId).toBe(5);
expect(state.outgoingShares.length).toBe(1);
});
});
describe('FE-STORE-VACAY-025: removeShare()', () => {
it('deletes the share and reloads shares and shared calendars', async () => {
useVacayStore.setState({
selectedYear: 2025,
outgoingShares: [{ id: 1, user_id: 5, username: 'Eve' }],
});
let deletedId: string | undefined;
server.use(
http.delete('/api/addons/vacay/shares/:id', ({ params }) => {
deletedId = params.id as string;
return HttpResponse.json({ success: true });
})
);
await useVacayStore.getState().removeShare(1);
const state = useVacayStore.getState();
expect(deletedId).toBe('1');
// Default MSW handlers return empty lists after the delete
expect(state.outgoingShares).toEqual([]);
expect(state.sharedCalendars).toEqual([]);
});
});
describe('FE-STORE-VACAY-026: setShareHidden()', () => {
it('optimistically toggles hidden on the share and its calendar', async () => {
useVacayStore.setState({
incomingShares: [{ id: 2, owner_id: 3, username: 'Carol', color: '#ec4899', hidden: false }],
sharedCalendars: [{ share_id: 2, owner_id: 3, owner_name: 'Carol', color: '#ec4899', hidden: false, entries: [], companyHolidays: [] }],
});
await useVacayStore.getState().setShareHidden(2, true);
const state = useVacayStore.getState();
expect(state.incomingShares[0].hidden).toBe(true);
expect(state.sharedCalendars[0].hidden).toBe(true);
});
it('rolls back the optimistic toggle when the API call fails', async () => {
useVacayStore.setState({
incomingShares: [{ id: 2, owner_id: 3, username: 'Carol', color: '#ec4899', hidden: false }],
sharedCalendars: [{ share_id: 2, owner_id: 3, owner_name: 'Carol', color: '#ec4899', hidden: false, entries: [], companyHolidays: [] }],
});
server.use(
http.put('/api/addons/vacay/shares/:id', () =>
HttpResponse.json({ error: 'Share not found' }, { status: 404 })
)
);
await expect(useVacayStore.getState().setShareHidden(2, true)).rejects.toThrow();
const state = useVacayStore.getState();
expect(state.incomingShares[0].hidden).toBe(false);
expect(state.sharedCalendars[0].hidden).toBe(false);
});
});
});
+18
View File
@@ -3709,6 +3709,24 @@ function runMigrations(db: Database.Database): void {
const hasFraction = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'fraction'").get();
if (!hasFraction) db.exec('ALTER TABLE vacay_entries ADD COLUMN fraction REAL NOT NULL DEFAULT 1');
},
// Read-only vacay calendar sharing (#444/#667): a user can let other users
// view their vacation calendar without fusing plans. owner_id is the sharing
// user, user_id the viewer; hidden lets the viewer hide the overlay without
// removing the share. Follows the person, not the plan, so it survives
// fusion and dissolution.
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS vacay_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
hidden INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (owner_id, user_id)
);
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id);');
},
];
if (currentVersion < migrations.length) {
+64
View File
@@ -11,6 +11,7 @@ import {
getStats as getVacayStats, updateStats as updateVacayStats,
addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar,
getCountries as getHolidayCountries, getHolidays,
listShares, shareCalendar, removeShare, getSharedCalendars,
} from '../../services/vacayService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
@@ -397,5 +398,68 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
return ok({ holidays: result.data });
}
);
if (R) server.registerTool(
'list_vacay_shares',
{
description: 'List read-only calendar shares: who the current user shares their vacation calendar with, and which calendars are shared with them.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
return ok(listShares(userId));
}
);
if (W) server.registerTool(
'share_vacay_calendar',
{
description: "Share the current user's vacation calendar with another user (view only, no merge).",
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const me = getCurrentUser(userId);
if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true };
const result = shareCalendar(userId, me.email, targetUserId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
if (W) server.registerTool(
'unshare_vacay_calendar',
{
description: 'Remove a read-only calendar share the current user is part of (revoke as owner, or remove a calendar shared with them).',
inputSchema: {
shareId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ shareId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!removeShare(shareId, userId, undefined)) {
return { content: [{ type: 'text' as const, text: 'Share not found.' }], isError: true };
}
return ok({ success: true });
}
);
if (R) server.registerTool(
'get_shared_vacay_calendars',
{
description: 'Get the read-only vacation calendars shared with the current user for a year (entries and company holidays per sharer).',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
return ok({ calendars: getSharedCalendars(userId, String(year)) });
}
);
}
}
+55
View File
@@ -11,10 +11,12 @@ import {
Put,
UseGuards,
} from '@nestjs/common';
import { vacayShareUpdateRequestSchema, type VacayShareUpdateRequest } from '@trek/shared';
import type { User } from '../../types';
import { VacayService } from './vacay.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
/**
* /api/addons/vacay — shared vacation-day planner.
@@ -235,6 +237,59 @@ export class VacayController {
return { success: true };
}
@Get('shares')
shares(@CurrentUser() user: User) {
return this.vacay.listShares(user.id);
}
@Post('shares')
@HttpCode(200)
share(
@CurrentUser() user: User,
@Body('user_id') userIdInput?: number | string,
@Headers('x-socket-id') socketId?: string,
) {
if (!userIdInput) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const result = this.vacay.shareCalendar(user.id, user.email, parseInt(String(userIdInput)), socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { success: true };
}
@Get('shares/available-users')
shareAvailableUsers(@CurrentUser() user: User) {
return { users: this.vacay.getShareAvailableUsers(user.id) };
}
@Get('shares/calendars/:year')
sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) {
return { calendars: this.vacay.getSharedCalendars(user.id, year) };
}
@Put('shares/:id')
updateShare(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body(new ZodValidationPipe(vacayShareUpdateRequestSchema)) body: VacayShareUpdateRequest,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.vacay.setShareHidden(parseInt(idParam), user.id, body.hidden, socketId)) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
}
@Delete('shares/:id')
deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
if (!this.vacay.removeShare(parseInt(idParam), user.id, socketId)) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
}
@Get('holidays/countries')
async holidayCountries() {
const result = await this.vacay.getCountries();
+24
View File
@@ -109,4 +109,28 @@ export class VacayService {
getHolidays(year: string, country: string) {
return svc.getHolidays(year, country);
}
listShares(userId: number) {
return svc.listShares(userId);
}
shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string) {
return svc.shareCalendar(ownerId, ownerEmail, targetUserId, socketId);
}
removeShare(shareId: number, userId: number, socketId: string | undefined): boolean {
return svc.removeShare(shareId, userId, socketId);
}
setShareHidden(shareId: number, userId: number, hidden: boolean, socketId: string | undefined): boolean {
return svc.setShareHidden(shareId, userId, hidden, socketId);
}
getShareAvailableUsers(userId: number) {
return svc.getShareAvailableUsers(userId);
}
getSharedCalendars(viewerId: number, year: string) {
return svc.getSharedCalendars(viewerId, year);
}
}
@@ -25,6 +25,7 @@ export type NotifEventType =
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
| 'vacay_share'
| 'collection_invite'
| 'photos_shared'
| 'collab_message'
@@ -40,6 +41,7 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [
'trip_reminder',
'todo_due',
'vacay_invite',
'vacay_share',
'collection_invite',
'photos_shared',
'collab_message',
@@ -93,6 +93,13 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_vacay',
navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null),
},
vacay_share: {
inAppType: 'navigate',
titleKey: 'notif.vacay_share.title',
textKey: 'notif.vacay_share.text',
navigateTextKey: 'notif.action.view_vacay',
navigateTarget: () => '/vacay',
},
collection_invite: {
inAppType: 'navigate',
titleKey: 'notif.collection_invite.title',
+207
View File
@@ -170,6 +170,22 @@ export function notifyPlanUsers(planId: number, excludeSid: string | undefined,
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId) as { user_id: number }[];
members.forEach(m => userIds.push(m.user_id));
userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
// Pending-invite events carry nothing a read-only viewer could see; every
// other event may change entries, colors or company holidays.
if (event !== 'vacay:invite' && event !== 'vacay:declined' && event !== 'vacay:cancelled') {
notifyShareViewers(userIds, excludeSid);
}
} catch { /* websocket not available */ }
}
export function notifyShareViewers(ownerIds: number[], excludeSid?: string): void {
if (ownerIds.length === 0) return;
try {
const { broadcastToUser } = require('../websocket');
const rows = db.prepare(
`SELECT DISTINCT user_id FROM vacay_shares WHERE owner_id IN (${ownerIds.map(() => '?').join(',')})`
).all(...ownerIds) as { user_id: number }[];
rows.forEach(r => broadcastToUser(r.user_id, { type: 'vacay:shared-update' }, excludeSid));
} catch { /* websocket not available */ }
}
@@ -487,6 +503,8 @@ export function dissolvePlan(userId: number, socketId: string | undefined): void
const { broadcastToUser } = require('../websocket');
allUserIds.filter(id => id !== userId).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' }));
} catch { /* */ }
// Everyone's entries just moved back to their own plans — refresh read-only viewers.
notifyShareViewers(allUserIds, socketId);
}
// ---------------------------------------------------------------------------
@@ -506,6 +524,195 @@ export function getAvailableUsers(userId: number, planId: number) {
`).all(userId, planId);
}
// ---------------------------------------------------------------------------
// Read-only calendar shares (#444/#667)
// ---------------------------------------------------------------------------
//
// A share lets another user VIEW someone's vacation days without fusing plans:
// no edit rights, no data migration. The share follows the person (owner_id),
// not a plan, so it keeps working across fusion and dissolution — viewers see
// the owner's entries in whatever plan the owner is currently active in.
export interface VacayShare {
id: number;
owner_id: number;
user_id: number;
hidden: number;
created_at?: string;
}
/** Like getActivePlan, but never lazily creates a plan for the user. */
function peekActivePlan(userId: number): VacayPlan | undefined {
const membership = db.prepare(`
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
`).get(userId) as { plan_id: number } | undefined;
if (membership) {
return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan | undefined;
}
return db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan | undefined;
}
/** Colors already taken in the viewer's own calendar (their plan's members). */
function viewerColors(viewerId: number): Set<string> {
const plan = peekActivePlan(viewerId);
if (!plan) return new Set(['#6366f1']);
const rows = db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ?').all(plan.id) as { color: string }[];
return new Set(rows.length > 0 ? rows.map(r => r.color) : ['#6366f1']);
}
/**
* Display color for a shared calendar. Starts from the owner's own color, but
* remaps to the first free preset when it collides with the viewer's plan
* members or an earlier share — otherwise two people on the default indigo
* would be indistinguishable in the overlay.
*/
function shareDisplayColor(ownerId: number, usedColors: Set<string>): string {
const plan = peekActivePlan(ownerId);
const row = plan
? db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(ownerId, plan.id) as { color: string } | undefined
: undefined;
let color = row?.color || '#6366f1';
if (usedColors.has(color)) {
// Preset pool exhausted? Derive a stable per-owner hue instead of colliding.
color = COLORS.find(c => !usedColors.has(c)) || `hsl(${Math.round((ownerId * 137.508) % 360)} 65% 60%)`;
}
usedColors.add(color);
return color;
}
/** Users the viewer already sees in full via their active plan (owner + members). */
function viewerCoMemberIds(viewerId: number): Set<number> {
const plan = peekActivePlan(viewerId);
return new Set(plan ? getPlanUsers(plan.id).map(u => u.id) : []);
}
export function listShares(userId: number) {
// Usernames only, like the share picker — emails stay out of the share surface.
const outgoing = db.prepare(`
SELECT s.id, s.user_id, u.username
FROM vacay_shares s JOIN users u ON s.user_id = u.id
WHERE s.owner_id = ? ORDER BY s.id
`).all(userId) as { id: number; user_id: number; username: string }[];
const incomingRows = db.prepare(`
SELECT s.id, s.owner_id, s.hidden, u.username
FROM vacay_shares s JOIN users u ON s.owner_id = u.id
WHERE s.user_id = ? ORDER BY s.id
`).all(userId) as { id: number; owner_id: number; hidden: number; username: string }[];
// Shares from someone the viewer is meanwhile fused with lie dormant — the
// plan already shows that calendar in full. They resume after dissolution.
const coMembers = viewerCoMemberIds(userId);
const usedColors = viewerColors(userId);
const incoming = incomingRows.filter(s => !coMembers.has(s.owner_id)).map(s => ({
id: s.id,
owner_id: s.owner_id,
username: s.username,
color: shareDisplayColor(s.owner_id, usedColors),
hidden: !!s.hidden,
}));
return { outgoing, incoming };
}
export function shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): { error?: string; status?: number } {
if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 };
const targetUser = db.prepare('SELECT id FROM users WHERE id = ?').get(targetUserId);
if (!targetUser) return { error: 'User not found', status: 404 };
const existing = db.prepare('SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?').get(ownerId, targetUserId);
if (existing) return { error: 'Already shared', status: 400 };
// Plan members already see the whole calendar — sharing with them is moot.
if (getPlanUsers(getActivePlanId(ownerId)).find(u => u.id === targetUserId)) {
return { error: 'User is already in your calendar', status: 400 };
}
db.prepare('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)').run(ownerId, targetUserId);
try {
const { broadcastToUser } = require('../websocket');
broadcastToUser(targetUserId, { type: 'vacay:share', from: { id: ownerId } });
// The owner's other devices refresh their outgoing list too.
broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId);
} catch { /* websocket not available */ }
import('../services/notificationService').then(({ send }) => {
send({ event: 'vacay_share', actorId: ownerId, scope: 'user', targetId: targetUserId, params: { actor: ownerEmail } }).catch(() => {});
});
return {};
}
export function removeShare(shareId: number, userId: number, socketId?: string): boolean {
const share = db.prepare('SELECT * FROM vacay_shares WHERE id = ?').get(shareId) as VacayShare | undefined;
// The owner revokes, the viewer removes — both may delete, nobody else.
if (!share || (share.owner_id !== userId && share.user_id !== userId)) return false;
db.prepare('DELETE FROM vacay_shares WHERE id = ?').run(shareId);
try {
const { broadcastToUser } = require('../websocket');
broadcastToUser(share.owner_id, { type: 'vacay:share-removed' }, socketId);
broadcastToUser(share.user_id, { type: 'vacay:share-removed' }, socketId);
} catch { /* websocket not available */ }
return true;
}
export function setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): boolean {
const share = db.prepare('SELECT * FROM vacay_shares WHERE id = ?').get(shareId) as VacayShare | undefined;
if (!share || share.user_id !== userId) return false;
db.prepare('UPDATE vacay_shares SET hidden = ? WHERE id = ?').run(hidden ? 1 : 0, shareId);
try {
// Keep the viewer's other devices in sync; nobody else is affected.
const { broadcastToUser } = require('../websocket');
broadcastToUser(userId, { type: 'vacay:shared-update' }, socketId);
} catch { /* websocket not available */ }
return true;
}
export function getShareAvailableUsers(userId: number) {
const planId = getActivePlanId(userId);
// Username only — unlike the fusion picker this lists users from other plans
// too, so exposing their emails here would widen the instance directory.
return db.prepare(`
SELECT u.id, u.username FROM users u
WHERE u.id != ?
AND u.id NOT IN (SELECT user_id FROM vacay_shares WHERE owner_id = ?)
AND u.id NOT IN (
SELECT owner_id FROM vacay_plans WHERE id = ?
UNION
SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'
)
ORDER BY u.username
`).all(userId, userId, planId, planId);
}
export function getSharedCalendars(viewerId: number, year: string) {
const shares = db.prepare(`
SELECT s.id, s.owner_id, s.hidden, u.username
FROM vacay_shares s JOIN users u ON s.owner_id = u.id
WHERE s.user_id = ? ORDER BY s.id
`).all(viewerId) as { id: number; owner_id: number; hidden: number; username: string }[];
// Same dormancy rule as listShares: fused co-members are already fully visible.
const coMembers = viewerCoMemberIds(viewerId);
const usedColors = viewerColors(viewerId);
return shares.filter(s => !coMembers.has(s.owner_id)).map(s => {
const color = shareDisplayColor(s.owner_id, usedColors);
const plan = peekActivePlan(s.owner_id);
if (!plan) {
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] };
}
const entries = db.prepare(
'SELECT date, fraction FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date LIKE ? ORDER BY date'
).all(plan.id, s.owner_id, `${year}-%`);
// Company holidays are plan-wide context for "when is this person off";
// only exposed while the owner has the feature enabled, and dates only —
// the note text may be authored by plan members who aren't part of the share.
const companyHolidays = plan.company_holidays_enabled
? db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ? ORDER BY date').all(plan.id, `${year}-%`)
: [];
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays };
});
}
// ---------------------------------------------------------------------------
// Years
// ---------------------------------------------------------------------------
+1
View File
@@ -78,6 +78,7 @@ const RESET_TABLES = [
'journey_trips',
'journeys',
// Vacay
'vacay_shares',
'vacay_entries',
'vacay_company_holidays',
'vacay_holiday_calendars',
+130
View File
@@ -538,3 +538,133 @@ describe('Vacay toggle entry for plan member', () => {
expect(res.status).toBe(200);
});
});
describe('Vacay read-only calendar shares', () => {
it('VACAY-039 — POST /shares shares the calendar with another user', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(a.id));
const res = await request(app)
.post('/api/addons/vacay/shares')
.set('Cookie', authCookie(a.id))
.send({ user_id: b.id });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('VACAY-040 — GET /shares lists the share as outgoing for A and incoming for B', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(a.id));
await request(app).post('/api/addons/vacay/shares').set('Cookie', authCookie(a.id)).send({ user_id: b.id });
const aRes = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(a.id));
expect(aRes.status).toBe(200);
expect(aRes.body.outgoing).toHaveLength(1);
expect(aRes.body.outgoing[0].user_id).toBe(b.id);
expect(aRes.body.incoming).toHaveLength(0);
const bRes = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(b.id));
expect(bRes.status).toBe(200);
expect(bRes.body.incoming).toHaveLength(1);
expect(bRes.body.incoming[0].owner_id).toBe(a.id);
expect(bRes.body.incoming[0].hidden).toBe(false);
expect(bRes.body.outgoing).toHaveLength(0);
});
it('VACAY-041 — GET /shares/calendars/:year exposes A entries and company holidays to B', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(a.id));
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(a.id)).send({ year: 2025 });
await request(app).put('/api/addons/vacay/plan').set('Cookie', authCookie(a.id)).send({ company_holidays_enabled: true });
await request(app).post('/api/addons/vacay/entries/toggle').set('Cookie', authCookie(a.id)).send({ date: '2025-06-16' });
await request(app).post('/api/addons/vacay/entries/company-holiday').set('Cookie', authCookie(a.id)).send({ date: '2025-12-25', note: 'Christmas' });
await request(app).post('/api/addons/vacay/shares').set('Cookie', authCookie(a.id)).send({ user_id: b.id });
const res = await request(app)
.get('/api/addons/vacay/shares/calendars/2025')
.set('Cookie', authCookie(b.id));
expect(res.status).toBe(200);
expect(res.body.calendars).toHaveLength(1);
const cal = res.body.calendars[0];
expect(cal.owner_id).toBe(a.id);
expect(cal.entries.map((e: { date: string }) => e.date)).toContain('2025-06-16');
expect(cal.companyHolidays.map((h: { date: string }) => h.date)).toContain('2025-12-25');
});
it('VACAY-042 — PUT /shares/:id lets B hide the shared calendar', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(a.id));
await request(app).post('/api/addons/vacay/shares').set('Cookie', authCookie(a.id)).send({ user_id: b.id });
const list = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(b.id));
const shareId = list.body.incoming[0].id;
const res = await request(app)
.put(`/api/addons/vacay/shares/${shareId}`)
.set('Cookie', authCookie(b.id))
.send({ hidden: true });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const after = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(b.id));
expect(after.body.incoming[0].hidden).toBe(true);
});
it('VACAY-043 — DELETE /shares/:id lets B remove the share', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(a.id));
await request(app).post('/api/addons/vacay/shares').set('Cookie', authCookie(a.id)).send({ user_id: b.id });
const list = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(b.id));
const shareId = list.body.incoming[0].id;
const res = await request(app)
.delete(`/api/addons/vacay/shares/${shareId}`)
.set('Cookie', authCookie(b.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const bAfter = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(b.id));
expect(bAfter.body.incoming).toHaveLength(0);
const aAfter = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(a.id));
expect(aAfter.body.outgoing).toHaveLength(0);
});
it('VACAY-044 — PUT/DELETE on a foreign share id returns 404', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
const { user: c } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(a.id));
await request(app).post('/api/addons/vacay/shares').set('Cookie', authCookie(a.id)).send({ user_id: b.id });
const list = await request(app).get('/api/addons/vacay/shares').set('Cookie', authCookie(b.id));
const shareId = list.body.incoming[0].id;
const put = await request(app)
.put(`/api/addons/vacay/shares/${shareId}`)
.set('Cookie', authCookie(c.id))
.send({ hidden: true });
expect(put.status).toBe(404);
const del = await request(app)
.delete(`/api/addons/vacay/shares/${shareId}`)
.set('Cookie', authCookie(c.id));
expect(del.status).toBe(404);
});
it('VACAY-045 — sharing with the same user twice returns 400', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(a.id));
await request(app).post('/api/addons/vacay/shares').set('Cookie', authCookie(a.id)).send({ user_id: b.id });
const res = await request(app)
.post('/api/addons/vacay/shares')
.set('Cookie', authCookie(a.id))
.send({ user_id: b.id });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Already shared');
});
});
+119
View File
@@ -57,6 +57,9 @@ vi.mock('../../../src/services/vacayService', async (importOriginal) => {
};
});
// share_vacay_calendar fires a user notification after inserting; stub it out
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
@@ -457,6 +460,122 @@ describe('Tool: list_holidays', () => {
});
});
// ---------------------------------------------------------------------------
// list_vacay_shares
// ---------------------------------------------------------------------------
describe('Tool: list_vacay_shares', () => {
it('returns outgoing and incoming shares', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } });
const result = await h.client.callTool({ name: 'list_vacay_shares', arguments: {} });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.outgoing)).toBe(true);
expect(Array.isArray(data.incoming)).toBe(true);
expect(data.outgoing).toHaveLength(1);
expect(data.outgoing[0].user_id).toBe(other.id);
});
});
});
// ---------------------------------------------------------------------------
// share_vacay_calendar
// ---------------------------------------------------------------------------
describe('Tool: share_vacay_calendar', () => {
it('creates a share and returns success', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?').get(user.id, other.id);
expect(row).toBeDefined();
});
});
it('errors when sharing with yourself', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: user.id } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const { user: other } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// unshare_vacay_calendar
// ---------------------------------------------------------------------------
describe('Tool: unshare_vacay_calendar', () => {
it('removes an existing share and returns success', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: other.id } });
const shareId = (testDb.prepare('SELECT id FROM vacay_shares WHERE owner_id = ?').get(user.id) as any).id;
const result = await h.client.callTool({ name: 'unshare_vacay_calendar', arguments: { shareId } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId)).toBeUndefined();
});
});
it('errors when the share does not exist', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unshare_vacay_calendar', arguments: { shareId: 99999 } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unshare_vacay_calendar', arguments: { shareId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_shared_vacay_calendars
// ---------------------------------------------------------------------------
describe('Tool: get_shared_vacay_calendars', () => {
it('returns the sharer entries for the viewer', async () => {
const { user: owner } = createUser(testDb);
const { user: viewer } = createUser(testDb);
await withHarness(owner.id, async (h) => {
await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
await h.client.callTool({ name: 'share_vacay_calendar', arguments: { targetUserId: viewer.id } });
});
await withHarness(viewer.id, async (h) => {
const result = await h.client.callTool({ name: 'get_shared_vacay_calendars', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.calendars)).toBe(true);
expect(data.calendars).toHaveLength(1);
expect(data.calendars[0].owner_id).toBe(owner.id);
expect(data.calendars[0].entries.map((e: any) => e.date)).toContain('2025-06-15');
});
});
});
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
@@ -178,4 +178,65 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 502, body: { error: 'upstream down' } }));
});
});
describe('read-only shares', () => {
it('GET /shares delegates listShares', () => {
const listShares = vi.fn().mockReturnValue({ outgoing: [{ id: 1 }], incoming: [] });
expect(makeController({ listShares }).shares(user)).toEqual({ outgoing: [{ id: 1 }], incoming: [] });
expect(listShares).toHaveBeenCalledWith(1);
});
it('400 when user_id missing on share', () => {
return thrown(() => makeController({}).share(user, undefined)).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }));
});
it('maps a shareCalendar error to its status', () => {
const shareCalendar = vi.fn().mockReturnValue({ error: 'Already shared', status: 400 });
return thrown(() => makeController({ shareCalendar }).share(user, 2)).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'Already shared' } }));
});
it('shares a calendar (user_id coerced to a number, socket id forwarded)', () => {
const shareCalendar = vi.fn().mockReturnValue({});
expect(makeController({ shareCalendar }).share(user, '2', 'sock-1')).toEqual({ success: true });
expect(shareCalendar).toHaveBeenCalledWith(1, 'u@example.test', 2, 'sock-1');
});
it('GET /shares/available-users wraps the user list', () => {
const getShareAvailableUsers = vi.fn().mockReturnValue([{ id: 3 }]);
expect(makeController({ getShareAvailableUsers }).shareAvailableUsers(user)).toEqual({ users: [{ id: 3 }] });
expect(getShareAvailableUsers).toHaveBeenCalledWith(1);
});
it('GET /shares/calendars/:year wraps the calendars', () => {
const getSharedCalendars = vi.fn().mockReturnValue([{ share_id: 4 }]);
expect(makeController({ getSharedCalendars }).sharedCalendars(user, '2026')).toEqual({ calendars: [{ share_id: 4 }] });
expect(getSharedCalendars).toHaveBeenCalledWith(1, '2026');
});
it('PUT /shares/:id forwards the socket id and the hidden flag', () => {
const setShareHidden = vi.fn().mockReturnValue(true);
expect(makeController({ setShareHidden }).updateShare(user, '7', { hidden: true }, 'sock-1')).toEqual({ success: true });
expect(setShareHidden).toHaveBeenCalledWith(7, 1, true, 'sock-1');
});
it('404 on PUT when the share is not toggleable by the caller', () => {
const setShareHidden = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ setShareHidden }).updateShare(user, '9', { hidden: true })).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Share not found' } }));
});
it('DELETE /shares/:id forwards the socket id', () => {
const removeShare = vi.fn().mockReturnValue(true);
expect(makeController({ removeShare }).deleteShare(user, '7', 'sock-1')).toEqual({ success: true });
expect(removeShare).toHaveBeenCalledWith(7, 1, 'sock-1');
});
it('404 on DELETE of a foreign share', () => {
const removeShare = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ removeShare }).deleteShare(user, '9')).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Share not found' } }));
});
});
});
@@ -95,14 +95,14 @@ describe('getPreferencesMatrix', () => {
const { event_types } = getPreferencesMatrix(user.id, 'user');
expect(event_types).not.toContain('version_available');
// +1 for plugin_notification: users can mute host-mediated plugin notifications.
expect(event_types.length).toBe(11);
expect(event_types.length).toBe(12);
});
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
const { user } = createAdmin(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(11);
expect(event_types.length).toBe(12);
});
it('NPREF-005b — admin scope returns only version_available', () => {
@@ -25,6 +25,8 @@ vi.mock('../../../src/config', () => ({
}));
// Mock websocket so notifyPlanUsers doesn't throw
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
// shareCalendar fires a notification after inserting — keep that out of unit scope
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -53,6 +55,12 @@ import {
toggleCompanyHoliday,
getStats,
applyHolidayCalendars,
listShares,
shareCalendar,
removeShare,
setShareHidden,
getShareAvailableUsers,
getSharedCalendars,
} from '../../../src/services/vacayService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
@@ -794,3 +802,269 @@ describe('applyHolidayCalendars', () => {
expect(remaining).toHaveLength(0);
});
});
// ── Read-only calendar shares (#444/#667) ─────────────────────────────────────
describe('shareCalendar', () => {
it('VACAY-SVC-048: inserts a share row and returns no error', () => {
const { user: owner } = setupUserWithPlan();
const { user: target } = createUser(testDb);
const result = shareCalendar(owner.id, owner.email, target.id);
expect(result.error).toBeUndefined();
const row = testDb
.prepare('SELECT * FROM vacay_shares WHERE owner_id = ? AND user_id = ?')
.get(owner.id, target.id);
expect(row).toBeDefined();
});
it('VACAY-SVC-049: returns 400 when sharing with yourself', () => {
const { user: owner } = setupUserWithPlan();
const result = shareCalendar(owner.id, owner.email, owner.id);
expect(result).toEqual({ error: 'Cannot share with yourself', status: 400 });
});
it('VACAY-SVC-050: returns 404 when the target user does not exist', () => {
const { user: owner } = setupUserWithPlan();
const result = shareCalendar(owner.id, owner.email, 99999);
expect(result).toEqual({ error: 'User not found', status: 404 });
});
it('VACAY-SVC-051: returns 400 when the share already exists', () => {
const { user: owner } = setupUserWithPlan();
const { user: target } = createUser(testDb);
shareCalendar(owner.id, owner.email, target.id);
const result = shareCalendar(owner.id, owner.email, target.id);
expect(result).toEqual({ error: 'Already shared', status: 400 });
});
it('VACAY-SVC-052: returns 400 when the target is already a member of the owner plan', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
insertMember(plan.id, member.id, 'accepted');
const result = shareCalendar(owner.id, owner.email, member.id);
expect(result).toEqual({ error: 'User is already in your calendar', status: 400 });
});
});
describe('listShares', () => {
it('VACAY-SVC-053: outgoing rows carry the target user info', () => {
const { user: owner } = setupUserWithPlan();
const { user: target } = createUser(testDb);
shareCalendar(owner.id, owner.email, target.id);
const result = listShares(owner.id);
expect(result.outgoing).toHaveLength(1);
expect(result.outgoing[0]).toMatchObject({
user_id: target.id,
username: target.username,
});
expect(result.outgoing[0]).not.toHaveProperty('email');
expect(result.incoming).toEqual([]);
});
it('VACAY-SVC-054: incoming rows carry the owner info, their color and a boolean hidden flag', () => {
const { user: owner, plan } = setupUserWithPlan();
setUserColor(owner.id, plan.id, '#ef4444', undefined);
const { user: viewer } = setupUserWithPlan();
shareCalendar(owner.id, owner.email, viewer.id);
const result = listShares(viewer.id);
expect(result.outgoing).toEqual([]);
expect(result.incoming).toHaveLength(1);
expect(result.incoming[0]).toMatchObject({
owner_id: owner.id,
username: owner.username,
color: '#ef4444',
hidden: false,
});
expect(result.incoming[0]).not.toHaveProperty('email');
});
it('VACAY-SVC-055: remaps colors when two sharing owners sit on the default indigo', () => {
const { user: viewer } = setupUserWithPlan(); // viewer's own color is #6366f1
const { user: owner1 } = setupUserWithPlan(); // default #6366f1
const { user: owner2 } = setupUserWithPlan(); // default #6366f1
shareCalendar(owner1.id, owner1.email, viewer.id);
shareCalendar(owner2.id, owner2.email, viewer.id);
const { incoming } = listShares(viewer.id);
expect(incoming).toHaveLength(2);
// Both collide with the viewer's own indigo, so each gets a distinct free preset
expect(incoming[0].color).not.toBe('#6366f1');
expect(incoming[1].color).not.toBe('#6366f1');
expect(incoming[0].color).not.toBe(incoming[1].color);
});
});
describe('removeShare', () => {
it('VACAY-SVC-056: the owner can revoke their share', () => {
const { user: owner } = setupUserWithPlan();
const { user: viewer } = createUser(testDb);
shareCalendar(owner.id, owner.email, viewer.id);
const shareId = listShares(owner.id).outgoing[0].id as number;
expect(removeShare(shareId, owner.id)).toBe(true);
const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId);
expect(row).toBeUndefined();
});
it('VACAY-SVC-057: the recipient can remove a share they received', () => {
const { user: owner } = setupUserWithPlan();
const { user: viewer } = createUser(testDb);
shareCalendar(owner.id, owner.email, viewer.id);
const shareId = listShares(viewer.id).incoming[0].id;
expect(removeShare(shareId, viewer.id)).toBe(true);
});
it('VACAY-SVC-058: a third user cannot remove the share, unknown ids return false', () => {
const { user: owner } = setupUserWithPlan();
const { user: viewer } = createUser(testDb);
const { user: stranger } = createUser(testDb);
shareCalendar(owner.id, owner.email, viewer.id);
const shareId = listShares(owner.id).outgoing[0].id as number;
expect(removeShare(shareId, stranger.id)).toBe(false);
const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId);
expect(row).toBeDefined();
expect(removeShare(99999, owner.id)).toBe(false);
});
});
describe('setShareHidden', () => {
it('VACAY-SVC-059: the recipient can hide and unhide the shared calendar', () => {
const { user: owner } = setupUserWithPlan();
const { user: viewer } = createUser(testDb);
shareCalendar(owner.id, owner.email, viewer.id);
const shareId = listShares(viewer.id).incoming[0].id;
expect(setShareHidden(shareId, viewer.id, true)).toBe(true);
let row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number };
expect(row.hidden).toBe(1);
expect(listShares(viewer.id).incoming[0].hidden).toBe(true);
expect(setShareHidden(shareId, viewer.id, false)).toBe(true);
row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number };
expect(row.hidden).toBe(0);
});
it('VACAY-SVC-060: the owner cannot toggle the recipient hidden flag', () => {
const { user: owner } = setupUserWithPlan();
const { user: viewer } = createUser(testDb);
shareCalendar(owner.id, owner.email, viewer.id);
const shareId = listShares(owner.id).outgoing[0].id as number;
expect(setShareHidden(shareId, owner.id, true)).toBe(false);
const row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number };
expect(row.hidden).toBe(0);
});
});
describe('getShareAvailableUsers', () => {
it('VACAY-SVC-061: excludes self, already-shared users and plan members', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
insertMember(plan.id, member.id, 'accepted');
const { user: shared } = createUser(testDb);
shareCalendar(owner.id, owner.email, shared.id);
const { user: unrelated } = createUser(testDb);
const ids = (getShareAvailableUsers(owner.id) as { id: number }[]).map(u => u.id);
expect(ids).toContain(unrelated.id);
expect(ids).not.toContain(owner.id);
expect(ids).not.toContain(member.id);
expect(ids).not.toContain(shared.id);
});
});
describe('getSharedCalendars', () => {
it('VACAY-SVC-062: returns only the owner entries of the shared plan, including fractions', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
insertMember(plan.id, member.id, 'accepted');
const { user: viewer } = setupUserWithPlan();
toggleEntry(owner.id, plan.id, '2025-06-10', 1);
toggleEntry(owner.id, plan.id, '2025-06-11', 0.5);
toggleEntry(member.id, plan.id, '2025-06-12', 1);
shareCalendar(owner.id, owner.email, viewer.id);
const calendars = getSharedCalendars(viewer.id, '2025');
expect(calendars).toHaveLength(1);
expect(calendars[0].owner_id).toBe(owner.id);
expect(calendars[0].owner_name).toBe(owner.username);
expect(calendars[0].hidden).toBe(false);
expect(calendars[0].entries).toEqual([
{ date: '2025-06-10', fraction: 1 },
{ date: '2025-06-11', fraction: 0.5 },
]);
});
it('VACAY-SVC-063: company holidays stay hidden while the owner plan has them disabled', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: viewer } = createUser(testDb);
testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 0 WHERE id = ?').run(plan.id);
toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined);
shareCalendar(owner.id, owner.email, viewer.id);
const calendars = getSharedCalendars(viewer.id, '2025');
expect(calendars[0].companyHolidays).toEqual([]);
});
it('VACAY-SVC-064: company holidays appear once the owner plan enables them', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: viewer } = createUser(testDb);
testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 1 WHERE id = ?').run(plan.id);
toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined);
shareCalendar(owner.id, owner.email, viewer.id);
const calendars = getSharedCalendars(viewer.id, '2025');
expect(calendars[0].companyHolidays).toEqual([{ date: '2025-12-24' }]);
});
it('VACAY-SVC-065: an owner without any plan yields empty arrays (no lazy creation)', () => {
const { user: owner } = createUser(testDb); // never touched vacay — no plan row
const { user: viewer } = createUser(testDb);
testDb.prepare('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)').run(owner.id, viewer.id);
const calendars = getSharedCalendars(viewer.id, '2025');
expect(calendars).toHaveLength(1);
expect(calendars[0].entries).toEqual([]);
expect(calendars[0].companyHolidays).toEqual([]);
const plan = testDb.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(owner.id);
expect(plan).toBeUndefined();
});
it('VACAY-SVC-066: follows an owner fused into another plan', () => {
const { user: host, plan: hostPlan } = setupUserWithPlan();
const { user: owner } = createUser(testDb);
getOwnPlan(owner.id);
insertMember(hostPlan.id, owner.id, 'accepted');
const { user: viewer } = createUser(testDb);
toggleEntry(owner.id, hostPlan.id, '2025-03-03', 1);
shareCalendar(owner.id, owner.email, viewer.id);
const calendars = getSharedCalendars(viewer.id, '2025');
expect(calendars).toHaveLength(1);
expect(calendars[0].entries).toEqual([{ date: '2025-03-03', fraction: 1 }]);
});
});
@@ -28,6 +28,10 @@ const ar: NotificationLocale = {
title: 'دعوة دمج الإجازة',
body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.`,
}),
vacay_share: (p) => ({
title: 'تمت مشاركة تقويم Vacay',
body: `${p.actor} شارك تقويم إجازاته معك. افتح TREK لعرضه.`,
}),
collection_invite: (p) => ({
title: 'دعوة إلى مجموعة',
body: `${p.actor} يدعوك لمشاركة مجموعة. افتح TREK للقبول أو الرفض.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}',
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.vacay_share.title': 'تمت مشاركة تقويم Vacay',
'notif.vacay_share.text': '{actor} شارك تقويم إجازاته معك',
'notif.collection_invite.title': 'دعوة إلى مجموعة',
'notif.collection_invite.text': '{actor} يدعوك لمشاركة مجموعة',
'notif.photos_shared.title': 'تمت مشاركة الصور',
+7 -3
View File
@@ -77,6 +77,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyVacayShare': 'مشاركات تقويم Vacay',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
@@ -414,7 +415,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'شريط التنقّل السفلي',
'settings.appearance.mobileNav.hint': 'اختَر العناصر التي تظهر في الشريط وتلك التي توضَع ضمن «المزيد». تبقى لوحة التحكّم دائماً في المقدّمة.',
'settings.appearance.mobileNav.hint':
'اختَر العناصر التي تظهر في الشريط وتلك التي توضَع ضمن «المزيد». تبقى لوحة التحكّم دائماً في المقدّمة.',
'settings.appearance.mobileNav.inBar': 'في الشريط',
'settings.appearance.mobileNav.underMore': 'ضمن «المزيد»',
'settings.appearance.mobileNav.moreEmpty': 'لا شيء هنا بعد — كل العناصر تتّسع في الشريط.',
@@ -422,7 +424,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'نقل إلى «المزيد»',
'settings.appearance.mobileNav.toBar': 'نقل إلى الشريط',
'settings.appearance.dashOrder': 'ترتيب لوحة التحكم',
'settings.appearance.dashOrder.hint': 'أعد ترتيب تسلسل قائمة الرحلات والأدوات على لوحة تحكم هاتفك. تبقى الرحلة المميّزة دائمًا في الأعلى.',
'settings.appearance.dashOrder.hint':
'أعد ترتيب تسلسل قائمة الرحلات والأدوات على لوحة تحكم هاتفك. تبقى الرحلة المميّزة دائمًا في الأعلى.',
'settings.appearance.dashOrder.trips': 'الرحلات',
'settings.appearance.dashOrder.hidden': 'مخفي',
'settings.general.languageRegion': 'Language & region',
@@ -490,7 +493,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'الوقت',
'settings.pluginActivity.columns.status': 'النتيجة',
'settings.alwaysShowRoutes': 'إظهار مسارات الحجوزات دائمًا',
'settings.alwaysShowRoutesHint': 'يعرض تلقائيًا مسار كل رحلة طيران وقطار وحجز آخر على الخريطة، دون الحاجة إلى تفعيله لكل عنصر على حدة.',
'settings.alwaysShowRoutesHint':
'يعرض تلقائيًا مسار كل رحلة طيران وقطار وحجز آخر على الخريطة، دون الحاجة إلى تفعيله لكل عنصر على حدة.',
};
export default settings;
+14
View File
@@ -92,5 +92,19 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'يمكن لكلا الطرفين حذف الإدخالات وتغيير مستحقات الإجازة.',
'vacay.fuseInfo4': 'تتم مشاركة الإعدادات مثل العطل الرسمية وعطل الشركة.',
'vacay.fuseInfo5': 'يمكن فك الدمج في أي وقت من قبل أي طرف. ستبقى إدخالاتك محفوظة.',
'vacay.sharedCalendars': 'التقويمات المشتركة',
'vacay.shareCalendar': 'مشاركة التقويم',
'vacay.shareCalendarHint': 'اسمح لمستخدم TREK آخر برؤية تقويم إجازاتك. عرض فقط — بدون دمج وبدون تعديل.',
'vacay.sharedWithYou': 'تمت مشاركته معك',
'vacay.youShareWith': 'تشاركه مع',
'vacay.viewOnly': 'عرض فقط',
'vacay.share': 'مشاركة',
'vacay.shareSent': 'تمت مشاركة التقويم',
'vacay.shareFailed': 'تعذرت مشاركة التقويم',
'vacay.stopSharing': 'إيقاف المشاركة',
'vacay.showInCalendar': 'إظهار في التقويم',
'vacay.hideFromCalendar': 'إخفاء من التقويم',
'vacay.sharedEmpty': 'اعرضا تقويميكما لبعضكما البعض دون دمجهما.',
'vacay.sharedLegend': 'مشترك (عرض فقط)',
};
export default vacay;
@@ -28,6 +28,10 @@ const br: NotificationLocale = {
title: 'Convite Vacay Fusion',
body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.`,
}),
vacay_share: (p) => ({
title: 'Calendário Vacay compartilhado',
body: `${p.actor} compartilhou o calendário de férias com você. Abra o TREK para visualizar.`,
}),
collection_invite: (p) => ({
title: 'Convite para coleção',
body: `${p.actor} convidou você para compartilhar uma coleção. Abra o TREK para aceitar ou recusar.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} em {trip} vence em {due}',
'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.vacay_share.title': 'Calendário Vacay compartilhado',
'notif.vacay_share.text': '{actor} compartilhou o calendário de férias com você',
'notif.collection_invite.title': 'Convite para coleção',
'notif.collection_invite.text': '{actor} convidou você para uma coleção',
'notif.photos_shared.title': 'Fotos compartilhadas',
+7 -3
View File
@@ -80,6 +80,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay',
'settings.notifyVacayShare': 'Compartilhamentos de calendário Vacay',
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
@@ -429,7 +430,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Barra inferior',
'settings.appearance.mobileNav.hint': 'Escolha quais itens aparecem na barra e quais ficam em “Mais”. O Dashboard vem sempre primeiro.',
'settings.appearance.mobileNav.hint':
'Escolha quais itens aparecem na barra e quais ficam em “Mais”. O Dashboard vem sempre primeiro.',
'settings.appearance.mobileNav.inBar': 'Na barra',
'settings.appearance.mobileNav.underMore': 'Em “Mais”',
'settings.appearance.mobileNav.moreEmpty': 'Nada aqui ainda — tudo cabe na barra.',
@@ -437,7 +439,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Mover para “Mais”',
'settings.appearance.mobileNav.toBar': 'Mover para a barra',
'settings.appearance.dashOrder': 'Ordem do painel',
'settings.appearance.dashOrder.hint': 'Reorganize como a lista de viagens e os widgets se empilham no painel do celular. A viagem em destaque fica sempre no topo.',
'settings.appearance.dashOrder.hint':
'Reorganize como a lista de viagens e os widgets se empilham no painel do celular. A viagem em destaque fica sempre no topo.',
'settings.appearance.dashOrder.trips': 'Viagens',
'settings.appearance.dashOrder.hidden': 'Oculto',
'settings.general.languageRegion': 'Language & region',
@@ -506,7 +509,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Quando',
'settings.pluginActivity.columns.status': 'Resultado',
'settings.alwaysShowRoutes': 'Sempre mostrar rotas de reserva',
'settings.alwaysShowRoutesHint': 'Mostra automaticamente no mapa a rota de cada voo, trem e outra reserva, sem precisar ativar item por item.',
'settings.alwaysShowRoutesHint':
'Mostra automaticamente no mapa a rota de cada voo, trem e outra reserva, sem precisar ativar item por item.',
};
export default settings;
+15
View File
@@ -94,5 +94,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo4': 'Configurações como feriados nacionais e da empresa são compartilhadas.',
'vacay.fuseInfo5':
'A fusão pode ser encerrada a qualquer momento por qualquer parte. Suas entradas serão preservadas.',
'vacay.sharedCalendars': 'Calendários compartilhados',
'vacay.shareCalendar': 'Compartilhar calendário',
'vacay.shareCalendarHint':
'Permita que outro usuário TREK veja seu calendário de férias. Somente visualização — sem fusão, sem edição.',
'vacay.sharedWithYou': 'Compartilhado com você',
'vacay.youShareWith': 'Você compartilha com',
'vacay.viewOnly': 'somente visualização',
'vacay.share': 'Compartilhar',
'vacay.shareSent': 'Calendário compartilhado',
'vacay.shareFailed': 'Não foi possível compartilhar o calendário',
'vacay.stopSharing': 'Parar de compartilhar',
'vacay.showInCalendar': 'Mostrar no calendário',
'vacay.hideFromCalendar': 'Ocultar do calendário',
'vacay.sharedEmpty': 'Mostrem seus calendários um ao outro sem fundi-los.',
'vacay.sharedLegend': 'Compartilhado (somente visualização)',
};
export default vacay;
@@ -28,6 +28,10 @@ const ca: NotificationLocale = {
title: 'Invitació a Vacay Fusion',
body: `${p.actor} et va convidar a fusionar plans de vacances. Obre TREK per acceptar o rebutjar.`,
}),
vacay_share: (p) => ({
title: 'Calendari de Vacay compartit',
body: `${p.actor} va compartir el seu calendari de vacances amb tu. Obre TREK per veure'l.`,
}),
photos_shared: (p) => ({
title: `${p.count} fotos compartides`,
body: `${p.actor} va compartir ${p.count} foto(s) a "${p.trip}".`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} a {trip} venç el {due}',
'notif.vacay_invite.title': 'Invitació a Vacay Fusion',
'notif.vacay_invite.text': '{actor} et va convidar a fusionar plans de vacances',
'notif.vacay_share.title': 'Calendari de Vacay compartit',
'notif.vacay_share.text': '{actor} va compartir el seu calendari de vacances amb tu',
'notif.photos_shared.title': 'Fotos compartides',
'notif.photos_shared.text': '{actor} va compartir {count} foto(s) a {trip}',
'notif.collab_message.title': 'Missatge nou',
+5 -2
View File
@@ -63,6 +63,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Recordatoris de viatge',
'settings.notifyTodoDue': 'Tasca propera',
'settings.notifyVacayInvite': 'Invitacions de fusió Vacay',
'settings.notifyVacayShare': 'Comparticions de calendari Vacay',
'settings.notifyPhotosShared': 'Fotos compartides (Immich)',
'settings.notifyCollabMessage': 'Missatges de xat (Col·laboració)',
'settings.notifyPackingTagged': "Llista d'equipatge: assignacions",
@@ -412,7 +413,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Adreces, etiquetes',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Barra de navegació inferior',
'settings.appearance.mobileNav.hint': 'Tria quins elements apareixen a la barra i quins queden sota «Més». El tauler sempre queda primer.',
'settings.appearance.mobileNav.hint':
'Tria quins elements apareixen a la barra i quins queden sota «Més». El tauler sempre queda primer.',
'settings.appearance.mobileNav.inBar': 'A la barra',
'settings.appearance.mobileNav.underMore': 'Sota «Més»',
'settings.appearance.mobileNav.moreEmpty': 'Encara no hi ha res — tot cap a la barra.',
@@ -420,7 +422,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Mou-ho sota «Més»',
'settings.appearance.mobileNav.toBar': 'Mou-ho a la barra',
'settings.appearance.dashOrder': 'Ordre del tauler',
'settings.appearance.dashOrder.hint': 'Reordena com s’apilen la llista de viatges i els ginys al tauler del mòbil. El viatge destacat sempre es manté a dalt.',
'settings.appearance.dashOrder.hint':
'Reordena com s’apilen la llista de viatges i els ginys al tauler del mòbil. El viatge destacat sempre es manté a dalt.',
'settings.appearance.dashOrder.trips': 'Viatges',
'settings.appearance.dashOrder.hidden': 'Ocult',
'settings.general.languageRegion': 'Idioma i regió',
+15
View File
@@ -89,6 +89,21 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo4': "Configuracions com festius i festius de l'empresa es comparteixen.",
'vacay.fuseInfo5':
'La fusió es pot dissoldre en qualsevol moment per qualsevol de les parts. Les teves entrades es conservaran.',
'vacay.sharedCalendars': 'Calendaris compartits',
'vacay.shareCalendar': 'Comparteix el calendari',
'vacay.shareCalendarHint':
'Permet que un altre usuari de TREK vegi el teu calendari de vacances. Només lectura — sense fusió ni edició.',
'vacay.sharedWithYou': 'Compartits amb tu',
'vacay.youShareWith': 'Comparteixes amb',
'vacay.viewOnly': 'només lectura',
'vacay.share': 'Comparteix',
'vacay.shareSent': 'Calendari compartit',
'vacay.shareFailed': "No s'ha pogut compartir el calendari",
'vacay.stopSharing': 'Deixa de compartir',
'vacay.showInCalendar': 'Mostra al calendari',
'vacay.hideFromCalendar': 'Amaga del calendari',
'vacay.sharedEmpty': "Mostreu-vos els calendaris l'un a l'altre sense fusionar-los.",
'vacay.sharedLegend': 'Compartit (només lectura)',
'vacay.addCalendar': 'Afegeix un calendari',
'vacay.calendarColor': 'Color del calendari',
'vacay.calendarLabel': 'Etiqueta',
@@ -28,6 +28,10 @@ const cs: NotificationLocale = {
title: 'Pozvánka Vacay Fusion',
body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.`,
}),
vacay_share: (p) => ({
title: 'Kalendář Vacay sdílen',
body: `${p.actor} s vámi sdílel svůj kalendář dovolených. Otevřete TREK pro zobrazení.`,
}),
collection_invite: (p) => ({
title: 'Pozvánka do sbírky',
body: `${p.actor} vás pozval ke sdílení sbírky. Otevřete TREK pro přijetí nebo odmítnutí.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} ve výletě {trip} má termín {due}',
'notif.vacay_invite.title': 'Pozvánka Vacay Fusion',
'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů',
'notif.vacay_share.title': 'Kalendář Vacay sdílen',
'notif.vacay_share.text': '{actor} s vámi sdílel svůj kalendář dovolených',
'notif.collection_invite.title': 'Pozvánka do sbírky',
'notif.collection_invite.text': '{actor} vás pozval do sbírky',
'notif.photos_shared.title': 'Fotky sdíleny',
+7 -3
View File
@@ -77,6 +77,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Připomínky cest',
'settings.notifyTodoDue': 'Úkol se blíží',
'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
'settings.notifyVacayShare': 'Sdílení kalendáře Vacay',
'settings.notifyPhotosShared': 'Sdílené fotky (Immich)',
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
@@ -422,7 +423,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Dolní navigační lišta',
'settings.appearance.mobileNav.hint': 'Vyberte, které položky se zobrazí v liště a které budou v nabídce „Více“. Dashboard zůstává vždy na prvním místě.',
'settings.appearance.mobileNav.hint':
'Vyberte, které položky se zobrazí v liště a které budou v nabídce „Více“. Dashboard zůstává vždy na prvním místě.',
'settings.appearance.mobileNav.inBar': 'V liště',
'settings.appearance.mobileNav.underMore': 'V nabídce „Více“',
'settings.appearance.mobileNav.moreEmpty': 'Zatím tu nic není — vše se vejde do lišty.',
@@ -430,7 +432,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Přesunout do nabídky „Více“',
'settings.appearance.mobileNav.toBar': 'Přesunout do lišty',
'settings.appearance.dashOrder': 'Pořadí na nástěnce',
'settings.appearance.dashOrder.hint': 'Změňte pořadí, ve kterém se na nástěnce v telefonu řadí seznam cest a widgety. Vybraná cesta zůstává vždy nahoře.',
'settings.appearance.dashOrder.hint':
'Změňte pořadí, ve kterém se na nástěnce v telefonu řadí seznam cest a widgety. Vybraná cesta zůstává vždy nahoře.',
'settings.appearance.dashOrder.trips': 'Cesty',
'settings.appearance.dashOrder.hidden': 'Skryto',
'settings.general.languageRegion': 'Language & region',
@@ -498,7 +501,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Kdy',
'settings.pluginActivity.columns.status': 'Výsledek',
'settings.alwaysShowRoutes': 'Vždy zobrazovat trasy rezervací',
'settings.alwaysShowRoutesHint': 'Automaticky zobrazí na mapě trasu každého letu, vlaku a jiné rezervace, aniž byste ji museli zapínat jednotlivě.',
'settings.alwaysShowRoutesHint':
'Automaticky zobrazí na mapě trasu každého letu, vlaku a jiné rezervace, aniž byste ji museli zapínat jednotlivě.',
};
export default settings;
+15
View File
@@ -92,5 +92,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'Obě strany mohou měnit nároky na dovolenou.',
'vacay.fuseInfo4': 'Nastavení (svátky, firemní volno) jsou sdílená.',
'vacay.fuseInfo5': 'Propojení lze kdykoli zrušit bez ztráty dat.',
'vacay.sharedCalendars': 'Sdílené kalendáře',
'vacay.shareCalendar': 'Sdílet kalendář',
'vacay.shareCalendarHint':
'Umožněte jinému uživateli TREK vidět váš kalendář dovolených. Pouze pro čtení — žádné propojení, žádné úpravy.',
'vacay.sharedWithYou': 'Sdíleno s vámi',
'vacay.youShareWith': 'Sdílíte s',
'vacay.viewOnly': 'pouze pro čtení',
'vacay.share': 'Sdílet',
'vacay.shareSent': 'Kalendář sdílen',
'vacay.shareFailed': 'Nepodařilo se sdílet kalendář',
'vacay.stopSharing': 'Ukončit sdílení',
'vacay.showInCalendar': 'Zobrazit v kalendáři',
'vacay.hideFromCalendar': 'Skrýt z kalendáře',
'vacay.sharedEmpty': 'Ukažte si navzájem své kalendáře, aniž byste je propojili.',
'vacay.sharedLegend': 'Sdílené (pouze pro čtení)',
};
export default vacay;
@@ -28,6 +28,10 @@ const de: NotificationLocale = {
title: 'Vacay Fusion-Einladung',
body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.`,
}),
vacay_share: (p) => ({
title: 'Vacay Kalender geteilt',
body: `${p.actor} hat den eigenen Urlaubskalender mit dir geteilt. Öffne TREK um ihn anzusehen.`,
}),
collection_invite: (p) => ({
title: 'Sammlungs-Einladung',
body: `${p.actor} hat dich eingeladen, eine Sammlung zu teilen. Öffne TREK um anzunehmen oder abzulehnen.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} in {trip} ist am {due} fällig',
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
'notif.vacay_share.title': 'Vacay Kalender geteilt',
'notif.vacay_share.text': '{actor} hat den eigenen Urlaubskalender mit dir geteilt',
'notif.collection_invite.title': 'Sammlungs-Einladung',
'notif.collection_invite.text': '{actor} hat dich zu einer Sammlung eingeladen',
'notif.photos_shared.title': 'Fotos geteilt',
+7 -3
View File
@@ -81,6 +81,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyTodoDue': 'Aufgabe bald fällig',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
'settings.notifyVacayShare': 'Vacay Kalender-Freigaben',
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
@@ -431,7 +432,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Adressen, Labels',
'settings.appearance.experimental': 'Experimentell',
'settings.appearance.mobileNav': 'Untere Navigationsleiste',
'settings.appearance.mobileNav.hint': 'Lege fest, welche Punkte in der Leiste erscheinen und welche unter „Mehr“ liegen. Das Dashboard bleibt immer an erster Stelle.',
'settings.appearance.mobileNav.hint':
'Lege fest, welche Punkte in der Leiste erscheinen und welche unter „Mehr“ liegen. Das Dashboard bleibt immer an erster Stelle.',
'settings.appearance.mobileNav.inBar': 'In der Leiste',
'settings.appearance.mobileNav.underMore': 'Unter „Mehr“',
'settings.appearance.mobileNav.moreEmpty': 'Noch nichts hier – alles passt in die Leiste.',
@@ -439,7 +441,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Unter „Mehr“ verschieben',
'settings.appearance.mobileNav.toBar': 'In die Leiste verschieben',
'settings.appearance.dashOrder': 'Dashboard-Reihenfolge',
'settings.appearance.dashOrder.hint': 'Ordne, wie Trip-Liste und Widgets auf deinem Handy-Dashboard gestapelt sind. Der hervorgehobene Trip bleibt immer oben.',
'settings.appearance.dashOrder.hint':
'Ordne, wie Trip-Liste und Widgets auf deinem Handy-Dashboard gestapelt sind. Der hervorgehobene Trip bleibt immer oben.',
'settings.appearance.dashOrder.trips': 'Trips',
'settings.appearance.dashOrder.hidden': 'Ausgeblendet',
'settings.general.languageRegion': 'Sprache & Region',
@@ -509,7 +512,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Zeitpunkt',
'settings.pluginActivity.columns.status': 'Ergebnis',
'settings.alwaysShowRoutes': 'Buchungsrouten immer anzeigen',
'settings.alwaysShowRoutesHint': 'Zeigt die Route für jeden Flug, jede Zugfahrt und jede andere Buchung automatisch auf der Karte an – ohne sie einzeln aktivieren zu müssen.',
'settings.alwaysShowRoutesHint':
'Zeigt die Route für jeden Flug, jede Zugfahrt und jede andere Buchung automatisch auf der Karte an – ohne sie einzeln aktivieren zu müssen.',
};
export default settings;
+15
View File
@@ -92,5 +92,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'Beide können Einträge löschen und den Urlaubsanspruch ändern.',
'vacay.fuseInfo4': 'Einstellungen wie Feiertage und Betriebsferien werden geteilt.',
'vacay.fuseInfo5': 'Die Fusion kann jederzeit von beiden Seiten aufgelöst werden. Einträge bleiben erhalten.',
'vacay.sharedCalendars': 'Geteilte Kalender',
'vacay.shareCalendar': 'Kalender teilen',
'vacay.shareCalendarHint':
'Lass einen anderen TREK-Benutzer deinen Urlaubskalender sehen. Nur ansehen — keine Fusion, kein Bearbeiten.',
'vacay.sharedWithYou': 'Mit dir geteilt',
'vacay.youShareWith': 'Du teilst mit',
'vacay.viewOnly': 'nur ansehen',
'vacay.share': 'Teilen',
'vacay.shareSent': 'Kalender geteilt',
'vacay.shareFailed': 'Kalender konnte nicht geteilt werden',
'vacay.stopSharing': 'Nicht mehr teilen',
'vacay.showInCalendar': 'Im Kalender anzeigen',
'vacay.hideFromCalendar': 'Im Kalender ausblenden',
'vacay.sharedEmpty': 'Zeigt euch gegenseitig eure Kalender, ohne sie zu fusionieren.',
'vacay.sharedLegend': 'Geteilt (nur ansehen)',
};
export default vacay;
@@ -28,6 +28,10 @@ const en: NotificationLocale = {
title: 'Vacay Fusion Invite',
body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.`,
}),
vacay_share: (p) => ({
title: 'Vacay Calendar Shared',
body: `${p.actor} shared their vacation calendar with you. Open TREK to view it.`,
}),
collection_invite: (p) => ({
title: 'Collection invite',
body: `${p.actor} invited you to share a collection. Open TREK to accept or decline.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} in {trip} is due on {due}',
'notif.vacay_invite.title': 'Vacay Fusion Invite',
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
'notif.vacay_share.title': 'Vacay Calendar Shared',
'notif.vacay_share.text': '{actor} shared their vacation calendar with you',
'notif.collection_invite.title': 'Collection invite',
'notif.collection_invite.text': '{actor} invited you to share a collection',
'notif.photos_shared.title': 'Photos Shared',
+7 -3
View File
@@ -91,6 +91,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyTodoDue': 'Todo due soon',
'settings.notifyVacayInvite': 'Vacay fusion invitations',
'settings.notifyVacayShare': 'Vacay calendar shares',
'settings.notifyPhotosShared': 'Shared photos (Immich)',
'settings.notifyCollabMessage': 'Chat messages (Collab)',
'settings.notifyPackingTagged': 'Packing list: assignments',
@@ -430,7 +431,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Bottom navbar',
'settings.appearance.mobileNav.hint': 'Choose which items appear in the bar and which sit under “More”. Dashboard always stays first.',
'settings.appearance.mobileNav.hint':
'Choose which items appear in the bar and which sit under “More”. Dashboard always stays first.',
'settings.appearance.mobileNav.inBar': 'In the bar',
'settings.appearance.mobileNav.underMore': 'Under “More”',
'settings.appearance.mobileNav.moreEmpty': 'Nothing here yet — everything fits in the bar.',
@@ -438,7 +440,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Move under “More”',
'settings.appearance.mobileNav.toBar': 'Move into the bar',
'settings.appearance.dashOrder': 'Dashboard order',
'settings.appearance.dashOrder.hint': 'Reorder how the trip list and widgets stack on your phone dashboard. The featured trip always stays on top.',
'settings.appearance.dashOrder.hint':
'Reorder how the trip list and widgets stack on your phone dashboard. The featured trip always stays on top.',
'settings.appearance.dashOrder.trips': 'Trips',
'settings.appearance.dashOrder.hidden': 'Hidden',
'settings.general.languageRegion': 'Language & region',
@@ -496,7 +499,8 @@ const settings: TranslationStrings = {
'settings.offline.banner.failed': 'Failed to sync: {count}',
'settings.offline.banner.conflicts': 'Conflicts: {count}',
'settings.alwaysShowRoutes': 'Always show booking routes',
'settings.alwaysShowRoutesHint': 'Automatically draw the route for every flight, train and other booking on the map — no need to switch it on per item.',
'settings.alwaysShowRoutesHint':
'Automatically draw the route for every flight, train and other booking on the map — no need to switch it on per item.',
};
export default settings;
+14
View File
@@ -92,5 +92,19 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'Both parties can delete entries and change vacation entitlements.',
'vacay.fuseInfo4': 'Settings like public holidays and company holidays are shared.',
'vacay.fuseInfo5': 'The fusion can be dissolved at any time by either party. Your entries will be preserved.',
'vacay.sharedCalendars': 'Shared Calendars',
'vacay.shareCalendar': 'Share calendar',
'vacay.shareCalendarHint': 'Let another TREK user see your vacation calendar. View only — no merge, no editing.',
'vacay.sharedWithYou': 'Shared with you',
'vacay.youShareWith': 'You share with',
'vacay.viewOnly': 'view only',
'vacay.share': 'Share',
'vacay.shareSent': 'Calendar shared',
'vacay.shareFailed': 'Could not share calendar',
'vacay.stopSharing': 'Stop sharing',
'vacay.showInCalendar': 'Show in calendar',
'vacay.hideFromCalendar': 'Hide from calendar',
'vacay.sharedEmpty': 'Show each other your calendars without merging them.',
'vacay.sharedLegend': 'Shared (view only)',
};
export default vacay;
@@ -28,6 +28,10 @@ const es: NotificationLocale = {
title: 'Invitación Vacay Fusion',
body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.`,
}),
vacay_share: (p) => ({
title: 'Calendario Vacay compartido',
body: `${p.actor} compartió su calendario de vacaciones contigo. Abre TREK para verlo.`,
}),
collection_invite: (p) => ({
title: 'Invitación a colección',
body: `${p.actor} te invitó a compartir una colección. Abre TREK para aceptar o rechazar.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} en {trip} vence el {due}',
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
'notif.vacay_share.title': 'Calendario Vacay compartido',
'notif.vacay_share.text': '{actor} compartió su calendario de vacaciones contigo',
'notif.collection_invite.title': 'Invitación a colección',
'notif.collection_invite.text': '{actor} te invitó a una colección',
'notif.photos_shared.title': 'Fotos compartidas',
+7 -3
View File
@@ -80,6 +80,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyTodoDue': 'Tarea próxima',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
'settings.notifyVacayShare': 'Calendarios Vacay compartidos',
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
@@ -430,7 +431,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Barra de navegación inferior',
'settings.appearance.mobileNav.hint': 'Elige qué elementos aparecen en la barra y cuáles quedan en «Más». Dashboard siempre va primero.',
'settings.appearance.mobileNav.hint':
'Elige qué elementos aparecen en la barra y cuáles quedan en «Más». Dashboard siempre va primero.',
'settings.appearance.mobileNav.inBar': 'En la barra',
'settings.appearance.mobileNav.underMore': 'En «Más»',
'settings.appearance.mobileNav.moreEmpty': 'Aún no hay nada aquí — todo cabe en la barra.',
@@ -438,7 +440,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Mover a «Más»',
'settings.appearance.mobileNav.toBar': 'Mover a la barra',
'settings.appearance.dashOrder': 'Orden del panel',
'settings.appearance.dashOrder.hint': 'Reordena cómo se apilan la lista de viajes y los widgets en el panel de tu teléfono. El viaje destacado siempre queda arriba.',
'settings.appearance.dashOrder.hint':
'Reordena cómo se apilan la lista de viajes y los widgets en el panel de tu teléfono. El viaje destacado siempre queda arriba.',
'settings.appearance.dashOrder.trips': 'Viajes',
'settings.appearance.dashOrder.hidden': 'Oculto',
'settings.general.languageRegion': 'Language & region',
@@ -507,7 +510,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Cuándo',
'settings.pluginActivity.columns.status': 'Resultado',
'settings.alwaysShowRoutes': 'Mostrar siempre las rutas de reserva',
'settings.alwaysShowRoutesHint': 'Muestra automáticamente en el mapa la ruta de cada vuelo, tren y otra reserva, sin necesidad de activarla una por una.',
'settings.alwaysShowRoutesHint':
'Muestra automáticamente en el mapa la ruta de cada vuelo, tren y otra reserva, sin necesidad de activarla una por una.',
};
export default settings;
+15
View File
@@ -89,6 +89,21 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.',
'vacay.fuseInfo5':
'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.',
'vacay.sharedCalendars': 'Calendarios compartidos',
'vacay.shareCalendar': 'Compartir calendario',
'vacay.shareCalendarHint':
'Permite que otro usuario de TREK vea tu calendario de vacaciones. Solo lectura: sin fusión y sin edición.',
'vacay.sharedWithYou': 'Compartidos contigo',
'vacay.youShareWith': 'Compartes con',
'vacay.viewOnly': 'solo lectura',
'vacay.share': 'Compartir',
'vacay.shareSent': 'Calendario compartido',
'vacay.shareFailed': 'No se pudo compartir el calendario',
'vacay.stopSharing': 'Dejar de compartir',
'vacay.showInCalendar': 'Mostrar en el calendario',
'vacay.hideFromCalendar': 'Ocultar del calendario',
'vacay.sharedEmpty': 'Enséñense sus calendarios sin fusionarlos.',
'vacay.sharedLegend': 'Compartido (solo lectura)',
'vacay.addCalendar': 'Añadir calendario',
'vacay.calendarColor': 'Color del calendario',
'vacay.calendarLabel': 'Etiqueta',
@@ -27,6 +27,7 @@ export type NotificationEventKey =
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
| 'vacay_share'
| 'collection_invite'
| 'photos_shared'
| 'collab_message'
@@ -28,6 +28,10 @@ const fr: NotificationLocale = {
title: 'Invitation Vacay Fusion',
body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.`,
}),
vacay_share: (p) => ({
title: 'Calendrier Vacay partagé',
body: `${p.actor} a partagé son calendrier de vacances avec vous. Ouvrez TREK pour le consulter.`,
}),
collection_invite: (p) => ({
title: 'Invitation à une collection',
body: `${p.actor} vous invite à partager une collection. Ouvrez TREK pour accepter ou refuser.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} dans {trip} est due le {due}',
'notif.vacay_invite.title': 'Invitation Vacay Fusion',
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
'notif.vacay_share.title': 'Calendrier Vacay partagé',
'notif.vacay_share.text': '{actor} a partagé son calendrier de vacances avec vous',
'notif.collection_invite.title': 'Invitation à une collection',
'notif.collection_invite.text': '{actor} vous invite à une collection',
'notif.photos_shared.title': 'Photos partagées',
+7 -3
View File
@@ -81,6 +81,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyTodoDue': 'Tâche à échéance',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
'settings.notifyVacayShare': 'Partages de calendrier Vacay',
'settings.notifyPhotosShared': 'Photos partagées (Immich)',
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
@@ -436,7 +437,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Barre de navigation',
'settings.appearance.mobileNav.hint': 'Choisissez les éléments à afficher dans la barre et ceux à placer sous « Plus ». Le tableau de bord reste toujours en premier.',
'settings.appearance.mobileNav.hint':
'Choisissez les éléments à afficher dans la barre et ceux à placer sous « Plus ». Le tableau de bord reste toujours en premier.',
'settings.appearance.mobileNav.inBar': 'Dans la barre',
'settings.appearance.mobileNav.underMore': 'Sous « Plus »',
'settings.appearance.mobileNav.moreEmpty': 'Rien ici pour l’instant — tout tient dans la barre.',
@@ -444,7 +446,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Déplacer sous « Plus »',
'settings.appearance.mobileNav.toBar': 'Déplacer dans la barre',
'settings.appearance.dashOrder': 'Ordre du tableau de bord',
'settings.appearance.dashOrder.hint': 'Réorganisez l’empilement de la liste des voyages et des widgets sur le tableau de bord de votre téléphone. Le voyage en vedette reste toujours en haut.',
'settings.appearance.dashOrder.hint':
'Réorganisez l’empilement de la liste des voyages et des widgets sur le tableau de bord de votre téléphone. Le voyage en vedette reste toujours en haut.',
'settings.appearance.dashOrder.trips': 'Voyages',
'settings.appearance.dashOrder.hidden': 'Masqué',
'settings.general.languageRegion': 'Language & region',
@@ -514,7 +517,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Quand',
'settings.pluginActivity.columns.status': 'Résultat',
'settings.alwaysShowRoutes': 'Toujours afficher les itinéraires de réservation',
'settings.alwaysShowRoutesHint': 'Affiche automatiquement sur la carte l\'itinéraire de chaque vol, trajet en train et autre réservation, sans avoir à l\'activer un par un.',
'settings.alwaysShowRoutesHint':
"Affiche automatiquement sur la carte l'itinéraire de chaque vol, trajet en train et autre réservation, sans avoir à l'activer un par un.",
};
export default settings;
+15
View File
@@ -90,6 +90,21 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo4': "Les paramètres comme les jours fériés et les jours d'entreprise sont partagés.",
'vacay.fuseInfo5':
"La fusion peut être dissoute à tout moment par l'une ou l'autre partie. Vos entrées seront préservées.",
'vacay.sharedCalendars': 'Calendriers partagés',
'vacay.shareCalendar': 'Partager le calendrier',
'vacay.shareCalendarHint':
'Permettez à un autre utilisateur TREK de voir votre calendrier de vacances. Lecture seule — pas de fusion, pas de modification.',
'vacay.sharedWithYou': 'Partagés avec vous',
'vacay.youShareWith': 'Vous partagez avec',
'vacay.viewOnly': 'lecture seule',
'vacay.share': 'Partager',
'vacay.shareSent': 'Calendrier partagé',
'vacay.shareFailed': 'Impossible de partager le calendrier',
'vacay.stopSharing': 'Arrêter le partage',
'vacay.showInCalendar': 'Afficher dans le calendrier',
'vacay.hideFromCalendar': 'Masquer du calendrier',
'vacay.sharedEmpty': 'Montrez-vous mutuellement vos calendriers sans les fusionner.',
'vacay.sharedLegend': 'Partagé (lecture seule)',
'vacay.addCalendar': 'Ajouter un calendrier',
'vacay.calendarColor': 'Couleur',
'vacay.calendarLabel': 'Libellé',
@@ -28,6 +28,10 @@ const gr: NotificationLocale = {
title: 'Πρόσκληση συγχώνευσης διακοπών',
body: `Ο/Η ${p.actor} σας προσκάλεσε να συγχωνεύσετε τα σχέδια διακοπών σας. Ανοίξτε το TREK για να αποδεχτείτε ή να απορρίψετε.`,
}),
vacay_share: (p) => ({
title: 'Κοινοποίηση Ημερολογίου Vacay',
body: `Ο/Η ${p.actor} κοινοποίησε το ημερολόγιο διακοπών του/της σε εσάς. Ανοίξτε το TREK για να το δείτε.`,
}),
collection_invite: (p) => ({
title: 'Πρόσκληση σε συλλογή',
body: `Ο/Η ${p.actor} σας προσκάλεσε να μοιραστείτε μια συλλογή. Ανοίξτε το TREK για να αποδεχτείτε ή να απορρίψετε.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': 'Η εργασία {todo} στο {trip} λήγει στις {due}',
'notif.vacay_invite.title': 'Πρόσκληση Σύντηξης Vacay',
'notif.vacay_invite.text': 'Ο/Η {actor} σας προσκάλεσε να συγχωνεύσετε τα σχέδια διακοπών',
'notif.vacay_share.title': 'Κοινοποίηση Ημερολογίου Vacay',
'notif.vacay_share.text': 'Ο/Η {actor} κοινοποίησε το ημερολόγιο διακοπών του/της σε εσάς',
'notif.collection_invite.title': 'Πρόσκληση σε συλλογή',
'notif.collection_invite.text': 'Ο/Η {actor} σας προσκάλεσε σε μια συλλογή',
'notif.photos_shared.title': 'Κοινοποίηση Φωτογραφιών',
+7 -3
View File
@@ -86,6 +86,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Υπενθυμίσεις ταξιδιού',
'settings.notifyTodoDue': 'Εκκρεμότητα λήγει σύντομα',
'settings.notifyVacayInvite': 'Προσκλήσεις Vacay fusion',
'settings.notifyVacayShare': 'Κοινοποιήσεις ημερολογίου Vacay',
'settings.notifyPhotosShared': 'Κοινόχρηστες φωτογραφίες (Immich)',
'settings.notifyCollabMessage': 'Μηνύματα συνομιλίας (Collab)',
'settings.notifyPackingTagged': 'Λίστα πακεταρίσματος: αναθέσεις',
@@ -439,7 +440,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Κάτω γραμμή πλοήγησης',
'settings.appearance.mobileNav.hint': 'Επιλέξτε ποια στοιχεία εμφανίζονται στη γραμμή και ποια βρίσκονται στο μενού «Περισσότερα». Ο πίνακας ελέγχου παραμένει πάντα πρώτος.',
'settings.appearance.mobileNav.hint':
'Επιλέξτε ποια στοιχεία εμφανίζονται στη γραμμή και ποια βρίσκονται στο μενού «Περισσότερα». Ο πίνακας ελέγχου παραμένει πάντα πρώτος.',
'settings.appearance.mobileNav.inBar': 'Στη γραμμή',
'settings.appearance.mobileNav.underMore': 'Στο μενού «Περισσότερα»',
'settings.appearance.mobileNav.moreEmpty': 'Τίποτα εδώ ακόμη — όλα χωρούν στη γραμμή.',
@@ -447,7 +449,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Μετακίνηση στο μενού «Περισσότερα»',
'settings.appearance.mobileNav.toBar': 'Μετακίνηση στη γραμμή',
'settings.appearance.dashOrder': 'Σειρά πίνακα',
'settings.appearance.dashOrder.hint': 'Αλλάξτε τη σειρά με την οποία στοιβάζονται η λίστα ταξιδιών και τα widget στον πίνακα του κινητού σας. Το προβεβλημένο ταξίδι παραμένει πάντα στην κορυφή.',
'settings.appearance.dashOrder.hint':
'Αλλάξτε τη σειρά με την οποία στοιβάζονται η λίστα ταξιδιών και τα widget στον πίνακα του κινητού σας. Το προβεβλημένο ταξίδι παραμένει πάντα στην κορυφή.',
'settings.appearance.dashOrder.trips': 'Ταξίδια',
'settings.appearance.dashOrder.hidden': 'Κρυφό',
'settings.general.languageRegion': 'Language & region',
@@ -517,7 +520,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Πότε',
'settings.pluginActivity.columns.status': 'Αποτέλεσμα',
'settings.alwaysShowRoutes': 'Πάντα εμφάνιση διαδρομών κράτησης',
'settings.alwaysShowRoutesHint': 'Εμφανίζει αυτόματα στον χάρτη τη διαδρομή κάθε πτήσης, τρένου και άλλης κράτησης, χωρίς να χρειάζεται να την ενεργοποιείτε μία προς μία.',
'settings.alwaysShowRoutesHint':
'Εμφανίζει αυτόματα στον χάρτη τη διαδρομή κάθε πτήσης, τρένου και άλλης κράτησης, χωρίς να χρειάζεται να την ενεργοποιείτε μία προς μία.',
};
export default settings;
+15
View File
@@ -94,5 +94,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo4': 'Ρυθμίσεις όπως δημόσιες αργίες και αργίες εταιρείας μοιράζονται.',
'vacay.fuseInfo5':
'Η συγχώνευση μπορεί να διαλυθεί ανά πάσα στιγμή από οποιοδήποτε μέρος. Οι καταχωρήσεις σας θα διατηρηθούν.',
'vacay.sharedCalendars': 'Κοινόχρηστα Ημερολόγια',
'vacay.shareCalendar': 'Κοινοποίηση ημερολογίου',
'vacay.shareCalendarHint':
'Επιτρέψτε σε έναν άλλο χρήστη TREK να βλέπει το ημερολόγιο διακοπών σας. Μόνο προβολή — χωρίς συγχώνευση, χωρίς επεξεργασία.',
'vacay.sharedWithYou': 'Κοινοποιήθηκαν σε εσάς',
'vacay.youShareWith': 'Κοινοποιείτε σε',
'vacay.viewOnly': 'μόνο προβολή',
'vacay.share': 'Κοινοποίηση',
'vacay.shareSent': 'Το ημερολόγιο κοινοποιήθηκε',
'vacay.shareFailed': 'Δεν ήταν δυνατή η κοινοποίηση του ημερολογίου',
'vacay.stopSharing': 'Διακοπή κοινοποίησης',
'vacay.showInCalendar': 'Εμφάνιση στο ημερολόγιο',
'vacay.hideFromCalendar': 'Απόκρυψη από το ημερολόγιο',
'vacay.sharedEmpty': 'Δείξτε ο ένας στον άλλο τα ημερολόγιά σας χωρίς να τα συγχωνεύσετε.',
'vacay.sharedLegend': 'Κοινόχρηστο (μόνο προβολή)',
};
export default vacay;
@@ -28,6 +28,10 @@ const hu: NotificationLocale = {
title: 'Vacay Fusion meghívó',
body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.`,
}),
vacay_share: (p) => ({
title: 'Vacay naptár megosztva',
body: `${p.actor} megosztotta veled a szabadságnaptárát. Nyissa meg a TREK-et a megtekintéshez.`,
}),
collection_invite: (p) => ({
title: 'Gyűjtemény meghívó',
body: `${p.actor} meghívott egy gyűjtemény megosztására. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} ({trip}) határideje: {due}',
'notif.vacay_invite.title': 'Vacay Fusion meghívó',
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
'notif.vacay_share.title': 'Vacay naptár megosztva',
'notif.vacay_share.text': '{actor} megosztotta veled a szabadságnaptárát',
'notif.collection_invite.title': 'Gyűjtemény meghívó',
'notif.collection_invite.text': '{actor} meghívott egy gyűjteménybe',
'notif.photos_shared.title': 'Fotók megosztva',
+7 -3
View File
@@ -79,6 +79,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Utazási emlékeztetők',
'settings.notifyTodoDue': 'Teendő esedékes',
'settings.notifyVacayInvite': 'Vacay összevonási meghívók',
'settings.notifyVacayShare': 'Vacay naptármegosztások',
'settings.notifyPhotosShared': 'Megosztott fotók (Immich)',
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések',
@@ -429,7 +430,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Alsó navigációs sáv',
'settings.appearance.mobileNav.hint': 'Válaszd ki, mely elemek jelenjenek meg a sávban, és melyek kerüljenek a „Továbbiak” alá. Az Irányítópult mindig elöl marad.',
'settings.appearance.mobileNav.hint':
'Válaszd ki, mely elemek jelenjenek meg a sávban, és melyek kerüljenek a „Továbbiak” alá. Az Irányítópult mindig elöl marad.',
'settings.appearance.mobileNav.inBar': 'A sávban',
'settings.appearance.mobileNav.underMore': 'A „Továbbiak” alatt',
'settings.appearance.mobileNav.moreEmpty': 'Itt még nincs semmi — minden elfér a sávban.',
@@ -437,7 +439,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Áthelyezés a „Továbbiak” alá',
'settings.appearance.mobileNav.toBar': 'Áthelyezés a sávba',
'settings.appearance.dashOrder': 'Irányítópult sorrendje',
'settings.appearance.dashOrder.hint': 'Rendezd át, hogyan legyenek egymás alatt az utazáslista és a widgetek a telefon irányítópultján. A kiemelt utazás mindig felül marad.',
'settings.appearance.dashOrder.hint':
'Rendezd át, hogyan legyenek egymás alatt az utazáslista és a widgetek a telefon irányítópultján. A kiemelt utazás mindig felül marad.',
'settings.appearance.dashOrder.trips': 'Utazások',
'settings.appearance.dashOrder.hidden': 'Rejtett',
'settings.general.languageRegion': 'Language & region',
@@ -507,7 +510,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Mikor',
'settings.pluginActivity.columns.status': 'Eredmény',
'settings.alwaysShowRoutes': 'Mindig jelenjenek meg a foglalási útvonalak',
'settings.alwaysShowRoutesHint': 'Automatikusan megjeleníti minden repülőjárat, vonat és egyéb foglalás útvonalát a térképen, nincs szükség egyenkénti bekapcsolásra.',
'settings.alwaysShowRoutesHint':
'Automatikusan megjeleníti minden repülőjárat, vonat és egyéb foglalás útvonalát a térképen, nincs szükség egyenkénti bekapcsolásra.',
};
export default settings;
+15
View File
@@ -92,5 +92,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'Mindkét fél törölhet bejegyzéseket és módosíthatja a szabadságkeretet.',
'vacay.fuseInfo4': 'A beállítások, mint ünnepnapok és céges szabadnapok, közösen érvényesek.',
'vacay.fuseInfo5': 'Az összevonás bármikor feloldható bármelyik fél által. A bejegyzések megmaradnak.',
'vacay.sharedCalendars': 'Megosztott naptárak',
'vacay.shareCalendar': 'Naptár megosztása',
'vacay.shareCalendarHint':
'Engedd, hogy egy másik TREK felhasználó lássa a szabadságnaptáradat. Csak megtekintés — nincs összevonás, nincs szerkesztés.',
'vacay.sharedWithYou': 'Veled megosztva',
'vacay.youShareWith': 'Te megosztod',
'vacay.viewOnly': 'csak megtekintés',
'vacay.share': 'Megosztás',
'vacay.shareSent': 'Naptár megosztva',
'vacay.shareFailed': 'Nem sikerült megosztani a naptárat',
'vacay.stopSharing': 'Megosztás leállítása',
'vacay.showInCalendar': 'Megjelenítés a naptárban',
'vacay.hideFromCalendar': 'Elrejtés a naptárból',
'vacay.sharedEmpty': 'Mutassátok meg egymásnak a naptáraitokat összevonás nélkül.',
'vacay.sharedLegend': 'Megosztott (csak megtekintés)',
};
export default vacay;
@@ -28,6 +28,10 @@ const id: NotificationLocale = {
title: 'Undangan Penggabungan Vacay',
body: `${p.actor} mengundang Anda untuk menggabungkan rencana liburan. Buka TREK untuk menerima atau menolak.`,
}),
vacay_share: (p) => ({
title: 'Kalender Vacay Dibagikan',
body: `${p.actor} membagikan kalender cutinya dengan Anda. Buka TREK untuk melihatnya.`,
}),
collection_invite: (p) => ({
title: 'Undangan koleksi',
body: `${p.actor} mengundang Anda untuk berbagi koleksi. Buka TREK untuk menerima atau menolak.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} di {trip} jatuh tempo pada {due}',
'notif.vacay_invite.title': 'Undangan Vacay Fusion',
'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan',
'notif.vacay_share.title': 'Kalender Vacay Dibagikan',
'notif.vacay_share.text': '{actor} membagikan kalender cutinya denganmu',
'notif.collection_invite.title': 'Undangan koleksi',
'notif.collection_invite.text': '{actor} mengundang Anda ke koleksi',
'notif.photos_shared.title': 'Foto Dibagikan',
+7 -3
View File
@@ -78,6 +78,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Pengingat perjalanan',
'settings.notifyTodoDue': 'Tugas jatuh tempo',
'settings.notifyVacayInvite': 'Undangan Vacay fusion',
'settings.notifyVacayShare': 'Berbagi kalender Vacay',
'settings.notifyPhotosShared': 'Foto dibagikan (Immich)',
'settings.notifyCollabMessage': 'Pesan chat (Collab)',
'settings.notifyPackingTagged': 'Daftar bawaan: penugasan',
@@ -427,7 +428,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Bilah navigasi bawah',
'settings.appearance.mobileNav.hint': 'Pilih item yang tampil di bilah dan yang masuk ke “Lainnya”. Dasbor selalu tetap di urutan pertama.',
'settings.appearance.mobileNav.hint':
'Pilih item yang tampil di bilah dan yang masuk ke “Lainnya”. Dasbor selalu tetap di urutan pertama.',
'settings.appearance.mobileNav.inBar': 'Di bilah',
'settings.appearance.mobileNav.underMore': 'Di bawah “Lainnya”',
'settings.appearance.mobileNav.moreEmpty': 'Belum ada apa pun di sini — semuanya muat di bilah.',
@@ -435,7 +437,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Pindahkan ke “Lainnya”',
'settings.appearance.mobileNav.toBar': 'Pindahkan ke bilah',
'settings.appearance.dashOrder': 'Urutan dasbor',
'settings.appearance.dashOrder.hint': 'Atur ulang susunan daftar perjalanan dan widget di dasbor ponsel Anda. Perjalanan unggulan selalu berada di atas.',
'settings.appearance.dashOrder.hint':
'Atur ulang susunan daftar perjalanan dan widget di dasbor ponsel Anda. Perjalanan unggulan selalu berada di atas.',
'settings.appearance.dashOrder.trips': 'Perjalanan',
'settings.appearance.dashOrder.hidden': 'Tersembunyi',
'settings.general.languageRegion': 'Language & region',
@@ -504,7 +507,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Waktu',
'settings.pluginActivity.columns.status': 'Hasil',
'settings.alwaysShowRoutes': 'Selalu tampilkan rute pemesanan',
'settings.alwaysShowRoutesHint': 'Menampilkan rute setiap penerbangan, kereta, dan pemesanan lainnya di peta secara otomatis, tanpa perlu mengaktifkannya satu per satu.',
'settings.alwaysShowRoutesHint':
'Menampilkan rute setiap penerbangan, kereta, dan pemesanan lainnya di peta secara otomatis, tanpa perlu mengaktifkannya satu per satu.',
};
export default settings;
+15
View File
@@ -92,5 +92,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'Kedua pihak dapat menghapus entri dan mengubah jatah cuti.',
'vacay.fuseInfo4': 'Pengaturan seperti hari libur nasional dan hari libur perusahaan dibagikan bersama.',
'vacay.fuseInfo5': 'Penggabungan dapat dipisahkan kapan saja oleh salah satu pihak. Entri kamu akan tetap disimpan.',
'vacay.sharedCalendars': 'Kalender yang Dibagikan',
'vacay.shareCalendar': 'Bagikan kalender',
'vacay.shareCalendarHint':
'Izinkan pengguna TREK lain melihat kalender cutimu. Hanya lihat — tanpa penggabungan, tanpa pengeditan.',
'vacay.sharedWithYou': 'Dibagikan kepadamu',
'vacay.youShareWith': 'Kamu berbagi dengan',
'vacay.viewOnly': 'hanya lihat',
'vacay.share': 'Bagikan',
'vacay.shareSent': 'Kalender dibagikan',
'vacay.shareFailed': 'Gagal membagikan kalender',
'vacay.stopSharing': 'Berhenti berbagi',
'vacay.showInCalendar': 'Tampilkan di kalender',
'vacay.hideFromCalendar': 'Sembunyikan dari kalender',
'vacay.sharedEmpty': 'Saling tunjukkan kalender kalian tanpa menggabungkannya.',
'vacay.sharedLegend': 'Dibagikan (hanya lihat)',
};
export default vacay;
@@ -28,6 +28,10 @@ const it: NotificationLocale = {
title: 'Invito Vacay Fusion',
body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.`,
}),
vacay_share: (p) => ({
title: 'Calendario Vacay condiviso',
body: `${p.actor} ha condiviso con te il suo calendario ferie. Apri TREK per vederlo.`,
}),
collection_invite: (p) => ({
title: 'Invito a una raccolta',
body: `${p.actor} ti ha invitato a condividere una raccolta. Apri TREK per accettare o rifiutare.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} in {trip} scade il {due}',
'notif.vacay_invite.title': 'Invito Vacay Fusion',
'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza',
'notif.vacay_share.title': 'Calendario Vacay condiviso',
'notif.vacay_share.text': '{actor} ha condiviso con te il suo calendario ferie',
'notif.collection_invite.title': 'Invito a una raccolta',
'notif.collection_invite.text': '{actor} ti ha invitato a una raccolta',
'notif.photos_shared.title': 'Foto condivise',
+7 -3
View File
@@ -80,6 +80,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Promemoria di viaggio',
'settings.notifyTodoDue': 'Attività in scadenza',
'settings.notifyVacayInvite': 'Inviti fusione Vacay',
'settings.notifyVacayShare': 'Condivisioni calendario Vacay',
'settings.notifyPhotosShared': 'Foto condivise (Immich)',
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
@@ -429,7 +430,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Barra di navigazione inferiore',
'settings.appearance.mobileNav.hint': 'Scegli quali elementi appaiono nella barra e quali sotto «Altro». La Dashboard resta sempre per prima.',
'settings.appearance.mobileNav.hint':
'Scegli quali elementi appaiono nella barra e quali sotto «Altro». La Dashboard resta sempre per prima.',
'settings.appearance.mobileNav.inBar': 'Nella barra',
'settings.appearance.mobileNav.underMore': 'Sotto «Altro»',
'settings.appearance.mobileNav.moreEmpty': 'Ancora niente qui — tutto entra nella barra.',
@@ -437,7 +439,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Sposta sotto «Altro»',
'settings.appearance.mobileNav.toBar': 'Sposta nella barra',
'settings.appearance.dashOrder': 'Ordine della dashboard',
'settings.appearance.dashOrder.hint': 'Riorganizza come si impilano l’elenco dei viaggi e i widget nella dashboard del telefono. Il viaggio in evidenza resta sempre in cima.',
'settings.appearance.dashOrder.hint':
'Riorganizza come si impilano l’elenco dei viaggi e i widget nella dashboard del telefono. Il viaggio in evidenza resta sempre in cima.',
'settings.appearance.dashOrder.trips': 'Viaggi',
'settings.appearance.dashOrder.hidden': 'Nascosto',
'settings.general.languageRegion': 'Language & region',
@@ -506,7 +509,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Quando',
'settings.pluginActivity.columns.status': 'Esito',
'settings.alwaysShowRoutes': 'Mostra sempre i percorsi delle prenotazioni',
'settings.alwaysShowRoutesHint': 'Mostra automaticamente sulla mappa il percorso di ogni volo, treno e altra prenotazione, senza doverlo attivare singolarmente.',
'settings.alwaysShowRoutesHint':
'Mostra automaticamente sulla mappa il percorso di ogni volo, treno e altra prenotazione, senza doverlo attivare singolarmente.',
};
export default settings;
+15
View File
@@ -94,5 +94,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo4': 'Le impostazioni come festività pubbliche e ferie aziendali sono condivise.',
'vacay.fuseInfo5':
"L'unione può essere sciolta in qualsiasi momento da una delle due parti. Le tue voci verranno preservate.",
'vacay.sharedCalendars': 'Calendari condivisi',
'vacay.shareCalendar': 'Condividi calendario',
'vacay.shareCalendarHint':
'Consenti a un altro utente TREK di vedere il tuo calendario ferie. Sola visualizzazione — nessuna unione, nessuna modifica.',
'vacay.sharedWithYou': 'Condivisi con te',
'vacay.youShareWith': 'Condividi con',
'vacay.viewOnly': 'sola visualizzazione',
'vacay.share': 'Condividi',
'vacay.shareSent': 'Calendario condiviso',
'vacay.shareFailed': 'Impossibile condividere il calendario',
'vacay.stopSharing': 'Interrompi condivisione',
'vacay.showInCalendar': 'Mostra nel calendario',
'vacay.hideFromCalendar': 'Nascondi dal calendario',
'vacay.sharedEmpty': 'Mostratevi i calendari a vicenda senza unirli.',
'vacay.sharedLegend': 'Condiviso (sola visualizzazione)',
};
export default vacay;
@@ -28,6 +28,10 @@ const ja: NotificationLocale = {
title: 'Vacay Fusion招待',
body: `${p.actor}が休暇プランの統合に招待しています。TREKを開いて承認または拒否してください。`,
}),
vacay_share: (p) => ({
title: 'Vacayカレンダーが共有されました',
body: `${p.actor}が休暇カレンダーを共有しました。TREKを開いて確認してください。`,
}),
collection_invite: (p) => ({
title: 'コレクション招待',
body: `${p.actor}がコレクションの共有に招待しています。TREKを開いて承認または拒否してください。`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '「{trip}」の{todo}は{due}が期限です',
'notif.vacay_invite.title': 'Vacay Fusionへの招待',
'notif.vacay_invite.text': '{actor}から旅行プランの統合に招待されました',
'notif.vacay_share.title': 'Vacayカレンダーが共有されました',
'notif.vacay_share.text': '{actor}が休暇カレンダーを共有しました',
'notif.collection_invite.title': 'コレクション招待',
'notif.collection_invite.text': '{actor}がコレクションに招待しました',
'notif.photos_shared.title': '写真が共有されました',
+7 -3
View File
@@ -79,6 +79,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': '旅行リマインダー',
'settings.notifyTodoDue': 'ToDoの期限',
'settings.notifyVacayInvite': 'Vacay fusion の招待',
'settings.notifyVacayShare': 'Vacay カレンダーの共有',
'settings.notifyPhotosShared': '共有写真(Immich)',
'settings.notifyCollabMessage': 'チャットメッセージ(Collab)',
'settings.notifyPackingTagged': '持ち物リスト:割り当て',
@@ -403,7 +404,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': '下部ナビゲーションバー',
'settings.appearance.mobileNav.hint': 'バーに表示する項目と「その他」に入れる項目を選べます。ダッシュボードは常に先頭に固定されます。',
'settings.appearance.mobileNav.hint':
'バーに表示する項目と「その他」に入れる項目を選べます。ダッシュボードは常に先頭に固定されます。',
'settings.appearance.mobileNav.inBar': 'バー内',
'settings.appearance.mobileNav.underMore': '「その他」内',
'settings.appearance.mobileNav.moreEmpty': 'まだ何もありません — すべてバーに収まっています。',
@@ -411,7 +413,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': '「その他」に移動',
'settings.appearance.mobileNav.toBar': 'バーに移動',
'settings.appearance.dashOrder': 'ダッシュボードの並び順',
'settings.appearance.dashOrder.hint': 'スマホのダッシュボードで旅行リストやウィジェットが並ぶ順番を変更できます。注目の旅行は常に一番上に固定されます。',
'settings.appearance.dashOrder.hint':
'スマホのダッシュボードで旅行リストやウィジェットが並ぶ順番を変更できます。注目の旅行は常に一番上に固定されます。',
'settings.appearance.dashOrder.trips': '旅行',
'settings.appearance.dashOrder.hidden': '非表示',
'settings.general.languageRegion': 'Language & region',
@@ -478,7 +481,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': '日時',
'settings.pluginActivity.columns.status': '結果',
'settings.alwaysShowRoutes': '予約ルートを常に表示',
'settings.alwaysShowRoutesHint': 'フライトや電車などすべての予約のルートを、個別にオンにしなくても地図上に自動的に表示します。',
'settings.alwaysShowRoutesHint':
'フライトや電車などすべての予約のルートを、個別にオンにしなくても地図上に自動的に表示します。',
};
export default settings;
+15
View File
@@ -92,5 +92,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': '双方が予定の削除や付与日数の変更を行えます。',
'vacay.fuseInfo4': '祝日や会社休日などの設定は共有されます。',
'vacay.fuseInfo5': '統合はいつでも解除できます。データは保持されます。',
'vacay.sharedCalendars': '共有カレンダー',
'vacay.shareCalendar': 'カレンダーを共有',
'vacay.shareCalendarHint':
'他のTREKユーザーに休暇カレンダーを見せることができます。閲覧のみ — 統合も編集もできません。',
'vacay.sharedWithYou': '共有されたカレンダー',
'vacay.youShareWith': '共有中の相手',
'vacay.viewOnly': '閲覧のみ',
'vacay.share': '共有',
'vacay.shareSent': 'カレンダーを共有しました',
'vacay.shareFailed': 'カレンダーを共有できませんでした',
'vacay.stopSharing': '共有を停止',
'vacay.showInCalendar': 'カレンダーに表示',
'vacay.hideFromCalendar': 'カレンダーから非表示',
'vacay.sharedEmpty': '統合せずにお互いのカレンダーを見せ合えます。',
'vacay.sharedLegend': '共有(閲覧のみ)',
};
export default vacay;
@@ -28,6 +28,10 @@ const ko: NotificationLocale = {
title: 'Vacay Fusion 초대',
body: `${p.actor}이(가) 휴가 계획을 합치도록 초대했습니다. TREK을 열어 수락하거나 거절하세요.`,
}),
vacay_share: (p) => ({
title: 'Vacay 캘린더 공유됨',
body: `${p.actor}이(가) 휴가 캘린더를 공유했습니다. TREK을 열어 확인하세요.`,
}),
collection_invite: (p) => ({
title: '컬렉션 초대',
body: `${p.actor}이(가) 컬렉션 공유에 초대했습니다. TREK을 열어 수락하거나 거절하세요.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{trip}의 {todo}이(가) {due}에 마감됩니다',
'notif.vacay_invite.title': 'Vacay 퓨전 초대',
'notif.vacay_invite.text': '{actor}이(가) 휴가 계획 공유에 초대했습니다',
'notif.vacay_share.title': 'Vacay 캘린더 공유됨',
'notif.vacay_share.text': '{actor}이(가) 휴가 캘린더를 공유했습니다',
'notif.collection_invite.title': '컬렉션 초대',
'notif.collection_invite.text': '{actor}이(가) 컬렉션에 초대했습니다',
'notif.photos_shared.title': '사진 공유됨',
+7 -3
View File
@@ -79,6 +79,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': '여행 리마인더',
'settings.notifyTodoDue': '할 일 마감 임박',
'settings.notifyVacayInvite': 'Vacay 퓨전 초대',
'settings.notifyVacayShare': 'Vacay 캘린더 공유',
'settings.notifyPhotosShared': '공유된 사진 (Immich)',
'settings.notifyCollabMessage': '채팅 메시지 (Collab)',
'settings.notifyPackingTagged': '짐 목록: 배정',
@@ -417,7 +418,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': '하단 내비게이션 바',
'settings.appearance.mobileNav.hint': '바에 표시할 항목과 「더보기」에 넣을 항목을 선택하세요. 대시보드는 항상 맨 앞에 고정됩니다.',
'settings.appearance.mobileNav.hint':
'바에 표시할 항목과 「더보기」에 넣을 항목을 선택하세요. 대시보드는 항상 맨 앞에 고정됩니다.',
'settings.appearance.mobileNav.inBar': '바에 표시',
'settings.appearance.mobileNav.underMore': '「더보기」에 표시',
'settings.appearance.mobileNav.moreEmpty': '아직 비어 있습니다 — 모든 항목이 바에 들어갑니다.',
@@ -425,7 +427,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': '「더보기」에 넣기',
'settings.appearance.mobileNav.toBar': '바에 넣기',
'settings.appearance.dashOrder': '대시보드 순서',
'settings.appearance.dashOrder.hint': '휴대폰 대시보드에서 여행 목록과 위젯이 표시되는 순서를 조정하세요. 대표 여행은 항상 맨 위에 고정됩니다.',
'settings.appearance.dashOrder.hint':
'휴대폰 대시보드에서 여행 목록과 위젯이 표시되는 순서를 조정하세요. 대표 여행은 항상 맨 위에 고정됩니다.',
'settings.appearance.dashOrder.trips': '여행',
'settings.appearance.dashOrder.hidden': '숨김',
'settings.general.languageRegion': 'Language & region',
@@ -494,7 +497,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': '시간',
'settings.pluginActivity.columns.status': '결과',
'settings.alwaysShowRoutes': '예약 경로 항상 표시',
'settings.alwaysShowRoutesHint': '항공편, 기차 등 모든 예약의 경로를 개별적으로 켤 필요 없이 지도에 자동으로 표시합니다.',
'settings.alwaysShowRoutesHint':
'항공편, 기차 등 모든 예약의 경로를 개별적으로 켤 필요 없이 지도에 자동으로 표시합니다.',
};
export default settings;
+15
View File
@@ -92,5 +92,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': '양측 모두 항목을 삭제하고 휴가 일수를 변경할 수 있습니다.',
'vacay.fuseInfo4': '공휴일 및 회사 휴일 등의 설정이 공유됩니다.',
'vacay.fuseInfo5': '어느 쪽이든 언제든지 퓨전을 해제할 수 있습니다. 항목은 보존됩니다.',
'vacay.sharedCalendars': '공유된 캘린더',
'vacay.shareCalendar': '캘린더 공유',
'vacay.shareCalendarHint':
'다른 TREK 사용자가 내 휴가 캘린더를 볼 수 있게 합니다. 보기 전용 — 퓨전이나 편집은 불가능합니다.',
'vacay.sharedWithYou': '나에게 공유됨',
'vacay.youShareWith': '내가 공유하는 대상',
'vacay.viewOnly': '보기 전용',
'vacay.share': '공유',
'vacay.shareSent': '캘린더가 공유되었습니다',
'vacay.shareFailed': '캘린더 공유 실패',
'vacay.stopSharing': '공유 중지',
'vacay.showInCalendar': '캘린더에 표시',
'vacay.hideFromCalendar': '캘린더에서 숨기기',
'vacay.sharedEmpty': '캘린더를 합치지 않고 서로에게 보여주세요.',
'vacay.sharedLegend': '공유됨 (보기 전용)',
};
export default vacay;
@@ -28,6 +28,10 @@ const nl: NotificationLocale = {
title: 'Vacay Fusion uitnodiging',
body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.`,
}),
vacay_share: (p) => ({
title: 'Vacay-kalender gedeeld',
body: `${p.actor} heeft zijn/haar vakantiekalender met je gedeeld. Open TREK om deze te bekijken.`,
}),
collection_invite: (p) => ({
title: 'Collectie-uitnodiging',
body: `${p.actor} nodigt je uit om een collectie te delen. Open TREK om te accepteren of af te wijzen.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} in {trip} verloopt op {due}',
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
'notif.vacay_share.title': 'Vacay-kalender gedeeld',
'notif.vacay_share.text': '{actor} heeft zijn/haar vakantiekalender met je gedeeld',
'notif.collection_invite.title': 'Collectie-uitnodiging',
'notif.collection_invite.text': '{actor} nodigt je uit voor een collectie',
'notif.photos_shared.title': "Foto's gedeeld",
+7 -3
View File
@@ -79,6 +79,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Reisherinneringen',
'settings.notifyTodoDue': 'Taak verloopt',
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
'settings.notifyVacayShare': 'Gedeelde Vacay-kalenders',
'settings.notifyPhotosShared': "Gedeelde foto's (Immich)",
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
'settings.notifyPackingTagged': 'Inpaklijst: toewijzingen',
@@ -430,7 +431,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Onderste navigatiebalk',
'settings.appearance.mobileNav.hint': 'Kies welke items in de balk verschijnen en welke onder “Meer” staan. Dashboard staat altijd vooraan.',
'settings.appearance.mobileNav.hint':
'Kies welke items in de balk verschijnen en welke onder “Meer” staan. Dashboard staat altijd vooraan.',
'settings.appearance.mobileNav.inBar': 'In de balk',
'settings.appearance.mobileNav.underMore': 'Onder “Meer”',
'settings.appearance.mobileNav.moreEmpty': 'Nog niets hier — alles past in de balk.',
@@ -438,7 +440,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Naar “Meer” verplaatsen',
'settings.appearance.mobileNav.toBar': 'Naar de balk verplaatsen',
'settings.appearance.dashOrder': 'Dashboardvolgorde',
'settings.appearance.dashOrder.hint': 'Wijzig de volgorde waarin de reislijst en widgets op je mobiele dashboard worden gestapeld. De uitgelichte reis blijft altijd bovenaan.',
'settings.appearance.dashOrder.hint':
'Wijzig de volgorde waarin de reislijst en widgets op je mobiele dashboard worden gestapeld. De uitgelichte reis blijft altijd bovenaan.',
'settings.appearance.dashOrder.trips': 'Reizen',
'settings.appearance.dashOrder.hidden': 'Verborgen',
'settings.general.languageRegion': 'Language & region',
@@ -507,7 +510,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Wanneer',
'settings.pluginActivity.columns.status': 'Resultaat',
'settings.alwaysShowRoutes': 'Boekingsroutes altijd tonen',
'settings.alwaysShowRoutesHint': 'Toont automatisch de route van elke vlucht, trein en andere boeking op de kaart, zonder dat u dit per boeking hoeft in te schakelen.',
'settings.alwaysShowRoutesHint':
'Toont automatisch de route van elke vlucht, trein en andere boeking op de kaart, zonder dat u dit per boeking hoeft in te schakelen.',
};
export default settings;
+15
View File
@@ -88,6 +88,21 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'Beide partijen kunnen invoeren verwijderen en vakantierechten wijzigen.',
'vacay.fuseInfo4': 'Instellingen zoals feestdagen en bedrijfsvakanties worden gedeeld.',
'vacay.fuseInfo5': 'De fusie kan op elk moment door beide partijen worden opgeheven. Je invoeren blijven behouden.',
'vacay.sharedCalendars': 'Gedeelde kalenders',
'vacay.shareCalendar': 'Kalender delen',
'vacay.shareCalendarHint':
'Laat een andere TREK-gebruiker je vakantiekalender zien. Alleen bekijken — geen fusie, geen bewerking.',
'vacay.sharedWithYou': 'Met jou gedeeld',
'vacay.youShareWith': 'Jij deelt met',
'vacay.viewOnly': 'alleen bekijken',
'vacay.share': 'Delen',
'vacay.shareSent': 'Kalender gedeeld',
'vacay.shareFailed': 'Kalender delen mislukt',
'vacay.stopSharing': 'Stoppen met delen',
'vacay.showInCalendar': 'Tonen in kalender',
'vacay.hideFromCalendar': 'Verbergen in kalender',
'vacay.sharedEmpty': 'Laat elkaar jullie kalenders zien zonder ze te fuseren.',
'vacay.sharedLegend': 'Gedeeld (alleen bekijken)',
'vacay.addCalendar': 'Kalender toevoegen',
'vacay.calendarColor': 'Kleur',
'vacay.calendarLabel': 'Label',
@@ -28,6 +28,10 @@ const pl: NotificationLocale = {
title: 'Zaproszenie Vacay Fusion',
body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.`,
}),
vacay_share: (p) => ({
title: 'Kalendarz Vacay udostępniony',
body: `${p.actor} udostępnił Ci swój kalendarz urlopów. Otwórz TREK, aby go zobaczyć.`,
}),
collection_invite: (p) => ({
title: 'Zaproszenie do kolekcji',
body: `${p.actor} zaprosił Cię do udostępnienia kolekcji. Otwórz TREK, aby zaakceptować lub odrzucić.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} w {trip} — termin {due}',
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
'notif.vacay_share.title': 'Kalendarz Vacay udostępniony',
'notif.vacay_share.text': '{actor} udostępnił Ci swój kalendarz urlopów',
'notif.collection_invite.title': 'Zaproszenie do kolekcji',
'notif.collection_invite.text': '{actor} zaprosił Cię do kolekcji',
'notif.photos_shared.title': 'Zdjęcia udostępnione',
+7 -3
View File
@@ -78,6 +78,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Przypomnienia o podróżach',
'settings.notifyTodoDue': 'Zadanie z terminem',
'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy',
'settings.notifyVacayShare': 'Udostępnienia kalendarza Vacay',
'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)',
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
'settings.notifyPackingTagged': 'Lista pakowania: przypisania',
@@ -429,7 +430,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Addresses, labels',
'settings.appearance.experimental': 'Experimental',
'settings.appearance.mobileNav': 'Dolny pasek nawigacji',
'settings.appearance.mobileNav.hint': 'Wybierz, które elementy pojawią się na pasku, a które w menu „Więcej”. Dashboard zawsze pozostaje pierwszy.',
'settings.appearance.mobileNav.hint':
'Wybierz, które elementy pojawią się na pasku, a które w menu „Więcej”. Dashboard zawsze pozostaje pierwszy.',
'settings.appearance.mobileNav.inBar': 'Na pasku',
'settings.appearance.mobileNav.underMore': 'W menu „Więcej”',
'settings.appearance.mobileNav.moreEmpty': 'Nic tu jeszcze nie ma — wszystko mieści się na pasku.',
@@ -437,7 +439,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Przenieś do menu „Więcej”',
'settings.appearance.mobileNav.toBar': 'Przenieś na pasek',
'settings.appearance.dashOrder': 'Kolejność panelu',
'settings.appearance.dashOrder.hint': 'Zmień kolejność układania listy podróży i widżetów na panelu telefonu. Wyróżniona podróż zawsze pozostaje na górze.',
'settings.appearance.dashOrder.hint':
'Zmień kolejność układania listy podróży i widżetów na panelu telefonu. Wyróżniona podróż zawsze pozostaje na górze.',
'settings.appearance.dashOrder.trips': 'Podróże',
'settings.appearance.dashOrder.hidden': 'Ukryte',
'settings.general.languageRegion': 'Language & region',
@@ -506,7 +509,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Kiedy',
'settings.pluginActivity.columns.status': 'Wynik',
'settings.alwaysShowRoutes': 'Zawsze pokazuj trasy rezerwacji',
'settings.alwaysShowRoutesHint': 'Automatycznie pokazuje na mapie trasę każdego lotu, pociągu i innej rezerwacji, bez konieczności włączania jej pojedynczo.',
'settings.alwaysShowRoutesHint':
'Automatycznie pokazuje na mapie trasę każdego lotu, pociągu i innej rezerwacji, bez konieczności włączania jej pojedynczo.',
};
export default settings;
+15
View File
@@ -93,5 +93,20 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo4': 'Ustawienia, takie jak święta państwowe i urlopy firmowe, są współdzielone.',
'vacay.fuseInfo5':
'Połączenie może zostać rozwiązane w dowolnym momencie przez każdą ze stron. Twoje wpisy zostaną zachowane.',
'vacay.sharedCalendars': 'Udostępnione kalendarze',
'vacay.shareCalendar': 'Udostępnij kalendarz',
'vacay.shareCalendarHint':
'Pozwól innemu użytkownikowi TREK zobaczyć Twój kalendarz urlopów. Tylko podgląd — bez łączenia i bez edycji.',
'vacay.sharedWithYou': 'Udostępnione Tobie',
'vacay.youShareWith': 'Udostępniasz dla',
'vacay.viewOnly': 'tylko podgląd',
'vacay.share': 'Udostępnij',
'vacay.shareSent': 'Kalendarz został udostępniony',
'vacay.shareFailed': 'Nie udało się udostępnić kalendarza',
'vacay.stopSharing': 'Przestań udostępniać',
'vacay.showInCalendar': 'Pokaż w kalendarzu',
'vacay.hideFromCalendar': 'Ukryj w kalendarzu',
'vacay.sharedEmpty': 'Pokażcie sobie nawzajem swoje kalendarze bez ich łączenia.',
'vacay.sharedLegend': 'Udostępnione (tylko podgląd)',
};
export default vacay;
@@ -28,6 +28,10 @@ const ru: NotificationLocale = {
title: 'Приглашение Vacay Fusion',
body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.`,
}),
vacay_share: (p) => ({
title: 'Календарь Vacay предоставлен',
body: `${p.actor} поделился с вами своим календарём отпусков. Откройте TREK, чтобы посмотреть.`,
}),
collection_invite: (p) => ({
title: 'Приглашение в коллекцию',
body: `${p.actor} приглашает вас поделиться коллекцией. Откройте TREK для подтверждения.`,
+2
View File
@@ -15,6 +15,8 @@ const notif: TranslationStrings = {
'notif.todo_due.text': '{todo} в {trip} — срок {due}',
'notif.vacay_invite.title': 'Приглашение Vacay Fusion',
'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска',
'notif.vacay_share.title': 'Календарь Vacay предоставлен',
'notif.vacay_share.text': '{actor} поделился с вами своим календарём отпусков',
'notif.collection_invite.title': 'Приглашение в коллекцию',
'notif.collection_invite.text': '{actor} приглашает вас в коллекцию',
'notif.photos_shared.title': 'Фото опубликованы',
+7 -3
View File
@@ -77,6 +77,7 @@ const settings: TranslationStrings = {
'settings.notifyTripReminder': 'Напоминания о поездке',
'settings.notifyTodoDue': 'Задача к сроку',
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
'settings.notifyVacayShare': 'Общий доступ к календарю Vacay',
'settings.notifyPhotosShared': 'Общие фото (Immich)',
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
'settings.notifyPackingTagged': 'Список вещей: назначения',
@@ -430,7 +431,8 @@ const settings: TranslationStrings = {
'settings.appearance.example.small': 'Адреса, подписи',
'settings.appearance.experimental': 'Экспериментальные настройки',
'settings.appearance.mobileNav': 'Нижняя панель навигации',
'settings.appearance.mobileNav.hint': 'Выберите, какие элементы отображаются на панели, а какие — в меню «Ещё». Дашборд всегда остаётся первым.',
'settings.appearance.mobileNav.hint':
'Выберите, какие элементы отображаются на панели, а какие — в меню «Ещё». Дашборд всегда остаётся первым.',
'settings.appearance.mobileNav.inBar': 'На панели',
'settings.appearance.mobileNav.underMore': 'В меню «Ещё»',
'settings.appearance.mobileNav.moreEmpty': 'Здесь пока пусто — всё умещается на панели.',
@@ -438,7 +440,8 @@ const settings: TranslationStrings = {
'settings.appearance.mobileNav.toMore': 'Переместить в меню «Ещё»',
'settings.appearance.mobileNav.toBar': 'Переместить на панель',
'settings.appearance.dashOrder': 'Порядок панели',
'settings.appearance.dashOrder.hint': 'Измените порядок, в котором список поездок и виджеты располагаются на панели телефона. Избранная поездка всегда остаётся сверху.',
'settings.appearance.dashOrder.hint':
'Измените порядок, в котором список поездок и виджеты располагаются на панели телефона. Избранная поездка всегда остаётся сверху.',
'settings.appearance.dashOrder.trips': 'Поездки',
'settings.appearance.dashOrder.hidden': 'Скрыто',
'settings.general.languageRegion': 'Язык и регион',
@@ -507,7 +510,8 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.when': 'Когда',
'settings.pluginActivity.columns.status': 'Результат',
'settings.alwaysShowRoutes': 'Всегда показывать маршруты бронирований',
'settings.alwaysShowRoutesHint': 'Автоматически показывает на карте маршрут для каждого рейса, поезда и другого бронирования — без необходимости включать это отдельно для каждого элемента.',
'settings.alwaysShowRoutesHint':
'Автоматически показывает на карте маршрут для каждого рейса, поезда и другого бронирования — без необходимости включать это отдельно для каждого элемента.',
};
export default settings;
+15
View File
@@ -88,6 +88,21 @@ const vacay: TranslationStrings = {
'vacay.fuseInfo3': 'Обе стороны могут удалять записи и изменять право на отпуск.',
'vacay.fuseInfo4': 'Настройки, такие как праздники и корпоративные выходные, становятся общими.',
'vacay.fuseInfo5': 'Объединение можно отменить в любое время любой из сторон. Ваши записи будут сохранены.',
'vacay.sharedCalendars': 'Общие календари',
'vacay.shareCalendar': 'Поделиться календарём',
'vacay.shareCalendarHint':
'Позвольте другому пользователю TREK видеть ваш календарь отпусков. Только просмотр — без объединения и редактирования.',
'vacay.sharedWithYou': 'Поделились с вами',
'vacay.youShareWith': 'Вы делитесь с',
'vacay.viewOnly': 'только просмотр',
'vacay.share': 'Поделиться',
'vacay.shareSent': 'Календарь предоставлен',
'vacay.shareFailed': 'Не удалось поделиться календарём',
'vacay.stopSharing': 'Закрыть доступ',
'vacay.showInCalendar': 'Показать в календаре',
'vacay.hideFromCalendar': 'Скрыть из календаря',
'vacay.sharedEmpty': 'Показывайте друг другу свои календари, не объединяя их.',
'vacay.sharedLegend': 'Общий (только просмотр)',
'vacay.addCalendar': 'Добавить календарь',
'vacay.calendarColor': 'Цвет',
'vacay.calendarLabel': 'Название',

Some files were not shown because too many files have changed in this diff Show More