mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
* 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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -78,6 +78,7 @@ const RESET_TABLES = [
|
||||
'journey_trips',
|
||||
'journeys',
|
||||
// Vacay
|
||||
'vacay_shares',
|
||||
'vacay_entries',
|
||||
'vacay_company_holidays',
|
||||
'vacay_holiday_calendars',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 للقبول أو الرفض.`,
|
||||
|
||||
@@ -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': 'تمت مشاركة الصور',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ó',
|
||||
|
||||
@@ -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í.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 για να αποδεχτείτε ή να απορρίψετε.`,
|
||||
|
||||
@@ -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': 'Κοινοποίηση Φωτογραφιών',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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を開いて承認または拒否してください。`,
|
||||
|
||||
@@ -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': '写真が共有されました',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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을 열어 수락하거나 거절하세요.`,
|
||||
|
||||
@@ -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': '사진 공유됨',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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ć.`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 для подтверждения.`,
|
||||
|
||||
@@ -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': 'Фото опубликованы',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user