diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx index dcaf1873d..7f6d7e80b 100644 --- a/client/src/components/Settings/NotificationsTab.tsx +++ b/client/src/components/Settings/NotificationsTab.tsx @@ -39,6 +39,7 @@ const EVENT_LABEL_KEYS: Record = { 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', diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index 344f79727..693f018b1 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -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('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 = {} + 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(() => (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() { ))} - {/* 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)) && (
{tipDate}
- {tipEntries.map((e, i) => { + {(tipEntries ?? []).map((e, i) => { const isHalf = (e.fraction ?? 1) === 0.5 return (
@@ -131,6 +154,16 @@ export default function VacayCalendar() {
) })} + {/* Shared calendars: ring dot instead of a filled one, like the grid. */} + {(tipShared ?? []).map((m, i) => ( +
+ + {m.name} + + {m.company ? t('vacay.companyHoliday') : (m.fraction ?? 1) === 0.5 ? t('vacay.modeHalf') : t('vacay.fullDay')} + +
+ ))}
)} diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index 78c6ddcaf..10cbfa718 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -11,6 +11,8 @@ interface VacayMonthCardProps { companyHolidaySet: Set companyHolidaysEnabled?: boolean entryMap: Record + // Shared read-only calendars per date (#444/#667) — rendered as rings, not fills. + sharedMap?: Record 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). */} diff --git a/client/src/components/Vacay/VacaySharedCalendars.test.tsx b/client/src/components/Vacay/VacaySharedCalendars.test.tsx new file mode 100644 index 000000000..6414b139f --- /dev/null +++ b/client/src/components/Vacay/VacaySharedCalendars.test.tsx @@ -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 = {}) { + 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() + + 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() + + 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() + + // 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() + + 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() + + 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() + + 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() + + // 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) + }) +}) diff --git a/client/src/components/Vacay/VacaySharedCalendars.tsx b/client/src/components/Vacay/VacaySharedCalendars.tsx new file mode 100644 index 000000000..4254fefa1 --- /dev/null +++ b/client/src/components/Vacay/VacaySharedCalendars.tsx @@ -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(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 ( +
+
+ {t('vacay.sharedCalendars')} + +
+ + {empty && ( +

{t('vacay.sharedEmpty')}

+ )} + + {incomingShares.length > 0 && ( +
+ {incomingShares.map(s => ( +
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). */} + + + {s.username} + + + + + {s.hidden + ? + : } + +
+ ))} +
+ )} + + {outgoingShares.length > 0 && ( +
0 ? 10 : 0 }}> + + {t('vacay.youShareWith')} + + {outgoingShares.map(s => ( +
+ + + {s.username} + + +
+ ))} +
+ )} + + {/* Share Modal — Portal to body to avoid z-index issues */} + {showShare && ReactDOM.createPortal( +
setShowShare(false)}> +
e.stopPropagation()}> +
+

{t('vacay.shareCalendar')}

+ +
+
+

{t('vacay.shareCalendarHint')}

+ {availableUsers.length === 0 ? ( +

{t('vacay.noUsersAvailable')}

+ ) : ( + setSelectedUser(Number(v))} + options={availableUsers.map(u => ({ value: u.id, label: u.username }))} + placeholder={t('vacay.selectUser')} + searchable + /> + )} +
+ + +
+
+
+
, + document.body + )} +
+ ) +} diff --git a/client/src/mobile/screens/settings/MSettingsNotifications.tsx b/client/src/mobile/screens/settings/MSettingsNotifications.tsx index cf168c857..03885ca17 100644 --- a/client/src/mobile/screens/settings/MSettingsNotifications.tsx +++ b/client/src/mobile/screens/settings/MSettingsNotifications.tsx @@ -38,6 +38,7 @@ const EVENT_LABEL_KEYS: Record = { 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', diff --git a/client/src/mobile/screens/vacay/MVacay.tsx b/client/src/mobile/screens/vacay/MVacay.tsx index a797ccb54..a8e243817 100644 --- a/client/src/mobile/screens/vacay/MVacay.tsx +++ b/client/src/mobile/screens/vacay/MVacay.tsx @@ -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() { v.setSheet('invite')} ariaLabel={t('vacay.inviteUser')}> + v.setSheet('share')} ariaLabel={t('vacay.sharedCalendars')}> + + ) })} + {/* Shared read-only calendars: tap toggles that person's ring overlay. */} + {v.incomingShares.map(s => ( + + ))} {v.companyHolidaysEnabled && ( )} @@ -280,6 +300,7 @@ export default function MVacay() { {/* Sheets */} v.setSheet(null)} /> v.setSheet(null)} /> + v.setSheet(null)} /> 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(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 ( + +
+
+
{t('vacay.sharedCalendars')}
+ + + +
+
+ {t('vacay.shareCalendarHint')} +
+ + {available.length === 0 ? ( +
+ {t('vacay.noUsersAvailable')} +
+ ) : ( + <> +
+ + +
+ {pickerOpen && ( +
+ {available.map(u => ( + + ))} +
+ )} + + )} + + {incomingShares.length > 0 && ( +
+
+ {t('vacay.sharedWithYou')} +
+ {incomingShares.map(s => ( +
+ + + {s.username} + + + +
+ ))} +
+ )} + + {outgoingShares.length > 0 && ( +
+
+ {t('vacay.youShareWith')} +
+ {outgoingShares.map(s => ( +
+ + {s.username} + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/client/src/mobile/screens/vacay/useMVacay.ts b/client/src/mobile/screens/vacay/useMVacay.ts index 84f35801a..00221e8bf 100644 --- a/client/src/mobile/screens/vacay/useMVacay.ts +++ b/client/src/mobile/screens/vacay/useMVacay.ts @@ -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 = {} + 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(() => ({ - 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, diff --git a/client/src/mobile/screens/vacay/vacayDayModel.ts b/client/src/mobile/screens/vacay/vacayDayModel.ts index 1a5b381ef..624a631b8 100644 --- a/client/src/mobile/screens/vacay/vacayDayModel.ts +++ b/client/src/mobile/screens/vacay/vacayDayModel.ts @@ -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 } 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 diff --git a/client/src/pages/VacayPage.test.tsx b/client/src/pages/VacayPage.test.tsx index 5ba0b906a..713bd29eb 100644 --- a/client/src/pages/VacayPage.test.tsx +++ b/client/src/pages/VacayPage.test.tsx @@ -30,6 +30,10 @@ vi.mock('../components/Vacay/VacaySettings', () => ({ ), })); +vi.mock('../components/Vacay/VacaySharedCalendars', () => ({ + default: () =>
, +})); + vi.mock('../components/Layout/Navbar', () => ({ default: () =>