feat(mobile): swipe through Vacay's months and years, people and legend in sheets

In the editor the months of the leave year page like the trip's days, the
neighbour sliding in with the finger; in the overview the plan's years do
the same (only between years the plan has, the arrows still create one).
The day pager now pages anything with an id and can sit in a page's flow.

The row of person and legend chips is two compact pills now: People opens a
sheet to pick the member and show or hide shared calendars, Legend opens one
that explains the company days and holiday calendars, which were never a
choice.
This commit is contained in:
Maurice
2026-11-06 12:57:49 +01:00
parent 496c85e6e5
commit 117af0f9b1
33 changed files with 299 additions and 77 deletions
+14 -4
View File
@@ -27,8 +27,11 @@ const SETTLE_MS = 90;
/** How close to a page edge counts as resting on it. */
const REST_PX = 2;
/** Anything with an id pages: trip days, a leave year's months, the plan's years. */
export type PagerItem = Pick<Day, 'id'>;
export interface MDayPagerProps {
days: Day[];
days: PagerItem[];
selectedDayId: number | null;
onSelectDay: (dayId: number) => void;
/** Draws one day. Pages other than the current one are inert and hidden
@@ -38,6 +41,12 @@ export interface MDayPagerProps {
locked?: boolean;
/** Live-region text after a swipe lands on a day. */
describeDay: (index: number, total: number) => string;
/**
* In the page's flow instead of filling a positioned parent: the strip takes
* the height of its tallest page, for content that sits in a scrolling column
* (Vacay's month editor and year grid) rather than over the whole screen.
*/
inFlow?: boolean;
}
interface Page {
@@ -49,7 +58,7 @@ interface Page {
/** The window of pages around the selection. A selection that points at no
* day (the whole-trip view, or a day deleted remotely) still gets its page,
* with the first day next to it as a way back. */
export function pagesFor(days: Day[], selectedDayId: number | null): Page[] {
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
const i = days.findIndex((d) => d.id === selectedDayId);
if (i < 0) {
const first = days[0];
@@ -74,6 +83,7 @@ export default function MDayPager({
renderDay,
locked = false,
describeDay,
inFlow = false,
}: MDayPagerProps) {
const stripRef = useRef<HTMLDivElement>(null);
const [announcement, setAnnouncement] = useState('');
@@ -177,7 +187,7 @@ export default function MDayPager({
<div
ref={stripRef}
data-day-pager=""
className={`absolute inset-0 flex snap-x snap-mandatory overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${locked ? 'overflow-x-hidden' : 'overflow-x-auto'}`}
className={`${inFlow ? 'relative items-start' : 'absolute inset-0'} flex snap-x snap-mandatory overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${locked ? 'overflow-x-hidden' : 'overflow-x-auto'}`}
>
{pages.map((page) => (
<div
@@ -186,7 +196,7 @@ export default function MDayPager({
data-page-active={page.active ? '' : undefined}
inert={!page.active}
aria-hidden={page.active ? undefined : true}
className="relative h-full w-full flex-none snap-center snap-always"
className={`relative w-full flex-none snap-center snap-always ${inFlow ? '' : 'h-full'}`}
>
{renderDay(page.dayId, page.active)}
</div>
+142 -52
View File
@@ -1,8 +1,9 @@
import { Building2, ChevronLeft, ChevronRight, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
import { Building2, Check, ChevronDown, ChevronLeft, ChevronRight, Eye, EyeOff, 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'
import MProgress from '../../components/MProgress'
import MDayPager from '../../components/MDayPager'
import { useTranslation } from '../../../i18n'
import { useMVacay } from './useMVacay'
import MVacayMonth from './MVacayMonth'
@@ -12,6 +13,11 @@ import MVacayShareSheet from './MVacayShareSheet'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
import { fmtDays } from '../../../components/Vacay/vacayFormat'
/** The company-holiday tint the calendar cells use (vacayDayModel). */
const COMPANY_COLOR = '#F5D9A6' // theme-lint-disable: the cells' fixed company-day tint
const GLASS_PILL_BUTTON = 'inline-flex h-8 flex-none items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 text-[0.75rem] font-bold text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]'
const SHEET_ROW = 'flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start text-[0.875rem] font-bold transition-transform active:scale-[.98]'
const WEEKDAY_KEYS_MONDAY = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
@@ -41,6 +47,14 @@ export default function MVacay() {
? (stat.remaining < 0 ? 'var(--m-st-danger)' : 'var(--m-st-pending)')
: v.selectedColor
const weekdayKeys = v.weekStart === 0 ? WEEKDAY_KEYS_SUNDAY : WEEKDAY_KEYS_MONDAY
// People to pick between: fused members, or shared calendars to show or hide.
const peopleChoosable = v.users.length > 1 || v.incomingShares.length > 0
const legendItems = [
...(v.companyHolidaysEnabled ? [{ key: 'company', color: COMPANY_COLOR, label: t('mobileVacay.companyLegend') }] : []),
...(v.holidaysEnabled ? (v.plan?.holiday_calendars ?? []).map(cal => ({ key: `cal-${cal.id}`, color: cal.color, label: cal.label || cal.region })) : []),
]
const yearPages = v.years.map(year => ({ id: year }))
const monthPages = v.months.map((_, slot) => ({ id: slot }))
return (
// h-dvh, not h-full: the shell stopped providing a definite height (#1809).
@@ -121,56 +135,64 @@ export default function MVacay() {
</div>
)}
{/* Person + legend chips */}
<div className="mx-[2px] mb-2 mt-[9px] flex flex-wrap items-center gap-[6px]">
{v.users.map(u => {
const color = u.color || FALLBACK_PERSON_COLOR
const active = u.id === v.selectedUserId
return (
{/* Who the calendar is for, and what its colours mean: two compact pills
instead of a row of chips. Only the people are a choice; the legend is
explanation, so it waits behind its own pill. */}
{(peopleChoosable || legendItems.length > 0) && (
<div className="mx-[2px] mb-2 mt-[9px] flex items-center gap-2">
{peopleChoosable && (
<button
key={u.id}
type="button"
onClick={() => v.selectPerson(u.id)}
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={active
? { background: `${color}1f`, border: `1px solid ${color}`, color: 'var(--m-ink)' }
: { background: 'var(--m-sheetop)', border: '1px solid var(--m-rowbr)', color: 'var(--m-muted)' }}
onClick={() => v.setSheet('people')}
aria-haspopup="dialog"
className={GLASS_PILL_BUTTON}
>
<span className="h-[9px] w-[9px] flex-none rounded-[3px]" style={{ background: color }} />
<span className="truncate">{u.username}</span>
<span className="flex -space-x-1">
{v.users.slice(0, 4).map(u => (
<span
key={u.id}
className="h-[14px] w-[14px] rounded-full ring-2 ring-[color:var(--m-glass)]"
style={{ background: u.color || FALLBACK_PERSON_COLOR }}
/>
))}
</span>
{t('mobileVacay.people')}
<ChevronDown size={13} strokeWidth={2.4} className="text-m-faint" aria-hidden="true" />
</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')} />
)}
{v.holidaysEnabled && (v.plan?.holiday_calendars ?? []).map(cal => (
<LegendChip key={cal.id} color={cal.color} label={cal.label || cal.region} />
))}
</div>
)}
{legendItems.length > 0 && (
<button
type="button"
onClick={() => v.setSheet('legend')}
aria-haspopup="dialog"
className={`${GLASS_PILL_BUTTON} ms-auto`}
>
<span className="flex gap-[3px]">
{legendItems.slice(0, 3).map(item => (
<span key={item.key} className="h-[9px] w-[9px] rounded-[3px]" style={{ background: item.color }} />
))}
</span>
{t('mobileVacay.legend')}
</button>
)}
</div>
)}
{/* Year grid */}
{/* Swipe between the plan's years, like pages. The neighbouring year
draws its calendar at once; its entries arrive once it lands. */}
{!edit && (
<MDayPager
inFlow
days={yearPages}
selectedDayId={v.selectedYear}
onSelectDay={v.selectYear}
describeDay={i => String(v.years[i] ?? '')}
renderDay={pageYear => (
<div className="grid grid-cols-2 gap-[10px]">
{/* Twelve months from the window start (#737) — Jan–Dec on a calendar
year, Jul–Jun once the leave year is shifted. */}
{v.months.map(({ year, month }, slot) => (
{v.monthsFor(pageYear ?? v.selectedYear).map(({ year, month }, slot) => (
<div key={`${year}-${month}`} className="rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-2">
{/* The month name is the comfortable way into the editor: the mini
cells below it are ~21px on a phone, precise enough to navigate
@@ -196,6 +218,8 @@ export default function MVacay() {
</div>
))}
</div>
)}
/>
)}
{/* Edit view: month quick nav + single month */}
@@ -219,12 +243,23 @@ export default function MVacay() {
))}
</div>
</div>
{/* Swipe between the months of the window, like pages. */}
<MDayPager
inFlow
days={monthPages}
selectedDayId={v.monthSlot}
onSelectDay={v.setMonthSlot}
describeDay={i => v.monthNameLongFor(i)}
renderDay={pageSlot => {
const slot = pageSlot ?? v.monthSlot
const m = v.months[slot] ?? v.activeMonth
return (
<div className="rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-[11px]">
<div className="mb-[7px] flex items-center gap-2">
<button type="button" onClick={v.prevMonth} aria-label={t('mobileVacay.prevMonth')} className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[color:var(--m-ic)]">
<ChevronLeft size={16} strokeWidth={2.2} />
</button>
<span className="flex-1 text-center text-[1rem] font-extrabold capitalize">{v.monthNameLong}</span>
<span className="flex-1 text-center text-[1rem] font-extrabold capitalize">{v.monthNameLongFor(slot)}</span>
<button type="button" onClick={v.nextMonth} aria-label={t('mobileVacay.nextMonth')} className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[color:var(--m-ic)]">
<ChevronRight size={16} strokeWidth={2.2} />
</button>
@@ -235,8 +270,8 @@ export default function MVacay() {
))}
</div>
<MVacayMonth
year={v.activeMonth.year}
month={v.activeMonth.month}
year={m.year}
month={m.month}
variant="full"
weekStart={v.weekStart}
ctx={v.dayCtx}
@@ -245,6 +280,9 @@ export default function MVacay() {
onDayTap={v.handleDayTap}
/>
</div>
)
}}
/>
</>
)}
</div>
@@ -338,6 +376,66 @@ export default function MVacay() {
<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)} />
<MSheet open={v.sheet === 'people'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.people')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-3 px-1 text-[0.8125rem] font-bold">{t('mobileVacay.people')}</div>
<div className="flex flex-col gap-2">
{v.users.map(u => {
const color = u.color || FALLBACK_PERSON_COLOR
const active = u.id === v.selectedUserId
return (
<button
key={u.id}
type="button"
onClick={() => { v.selectPerson(u.id); v.setSheet(null) }}
aria-pressed={active}
className={`${SHEET_ROW} ${active ? 'border-transparent bg-m-act text-m-actfg' : ''}`}
>
<span className="h-[14px] w-[14px] flex-none rounded-full" style={{ background: color }} />
<span className="min-w-0 flex-1 truncate">{u.username}</span>
{active && <Check size={16} strokeWidth={2.4} aria-hidden="true" />}
</button>
)
})}
</div>
{/* Shared read-only calendars: a tap shows or hides that person's ring overlay. */}
{v.incomingShares.length > 0 && (
<>
<div className="mb-2 mt-4 px-1 font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{t('vacay.sharedCalendars')}</div>
<div className="flex flex-col gap-2">
{v.incomingShares.map(sh => (
<button
key={`share-${sh.id}`}
type="button"
onClick={() => v.toggleShareHidden(sh.id, !sh.hidden)}
aria-pressed={!sh.hidden}
className={`${SHEET_ROW} ${sh.hidden ? 'opacity-60' : ''}`}
>
<span className="h-[14px] w-[14px] flex-none rounded-full" style={{ border: `2px solid ${sh.color}` }} />
<span className="min-w-0 flex-1 truncate">{sh.username}</span>
{sh.hidden
? <EyeOff size={16} strokeWidth={2.2} className="text-m-faint" aria-hidden="true" />
: <Eye size={16} strokeWidth={2.2} className="text-m-muted" aria-hidden="true" />}
</button>
))}
</div>
</>
)}
</div>
</MSheet>
<MSheet open={v.sheet === 'legend'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.legend')}>
<div className="px-4 pb-4 pt-6">
<div className="mb-3 px-1 text-[0.8125rem] font-bold">{t('mobileVacay.legend')}</div>
<div className="flex flex-col gap-1">
{legendItems.map(item => (
<div key={item.key} className="flex items-center gap-3 px-1 py-2 text-[0.875rem] font-semibold">
<span className="h-[14px] w-[14px] flex-none rounded-[4px]" style={{ background: item.color }} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
</div>
))}
</div>
</div>
</MSheet>
<MVacayIncomingInvite
invites={v.incomingInvites}
onAccept={v.acceptInvite}
@@ -347,14 +445,6 @@ export default function MVacay() {
)
}
function LegendChip({ color, label }: { color: string; label: string }) {
return (
<span className="box-border inline-flex min-w-0 flex-[1_1_calc(25%-6px)] items-center justify-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-1 py-1 font-geist text-[0.625rem] font-bold text-m-muted">
<span className="h-[9px] w-[9px] flex-none rounded-[3px]" style={{ background: color }} />
<span className="truncate">{label}</span>
</span>
)
}
/** Forced Fusion-request card — stays open until accepted or declined. */
function MVacayIncomingInvite({ invites, onAccept, onDecline }: {
+16 -2
View File
@@ -14,7 +14,7 @@ import {
export type MVacayView = 'grid' | 'edit'
export type MVacayMode = VacayMode
export type MVacaySheet = 'invite' | 'settings' | 'share' | null
export type MVacaySheet = 'invite' | 'settings' | 'share' | 'people' | 'legend' | null
/**
* Screen state of the mobile Vacay experience. Data loading, WebSocket sync
@@ -96,6 +96,14 @@ export function useMVacay() {
[locale, activeMonth],
)
// The pagers draw the neighbours of the open month and year too: the long name
// of any slot of the window, and the twelve months of any year.
const monthNameLongFor = useCallback((slot: number) => {
const m = months[slot] ?? months[0]
return new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(m.year, m.month, 1))
}, [locale, months])
const monthsFor = useCallback((year: number) => windowMonths(year, yearSettings), [yearSettings])
const selectedUser = users.find(u => u.id === selectedUserId)
const selectedColor = selectedUser?.color || FALLBACK_PERSON_COLOR
const selectedStat = stats.find(s => s.user_id === selectedUserId)
@@ -156,6 +164,12 @@ export function useMVacay() {
else { await handleAddPrevYear(); setSelectedYear(selectedYear - 1) }
}, [years, selectedYear, setSelectedYear, handleAddPrevYear])
// A swipe only moves between years the plan already has; creating one stays
// with the arrows, where it is a deliberate step.
const selectYear = useCallback((year: number) => {
if (years.includes(year)) setSelectedYear(year)
}, [years, setSelectedYear])
const nextYear = useCallback(async () => {
const idx = years.indexOf(selectedYear)
if (idx >= 0 && idx < years.length - 1) setSelectedYear(years[idx + 1])
@@ -172,7 +186,7 @@ export function useMVacay() {
const tripDotColor = users.find(u => u.id === currentUser?.id)?.color || 'var(--m-st-info)'
return {
loading, plan, selectedYear,
loading, plan, selectedYear, years, selectYear, monthsFor, monthNameLongFor,
users, isFused, currentUser,
incomingInvites, acceptInvite, declineInvite,
incomingShares, toggleShareHidden,
+58 -19
View File
@@ -45,6 +45,14 @@ const dayCtx: DayVisualContext = {
};
const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
// The paging itself is MDayPager's, tested there. This draws only the current
// page, so the year grid and the month editor under test are the ones on screen.
vi.mock('../../../../src/mobile/components/MDayPager', () => ({
default: (props: { selectedDayId: number | null; renderDay: (id: number | null, active: boolean) => unknown }) =>
props.renderDay(props.selectedDayId, true),
}));
/** The screen flips the modifier flags with an updater, so the mock has to run it. */
function boolSetter() {
@@ -58,6 +66,10 @@ function buildV(over: Record<string, unknown> = {}): Record<string, unknown> {
loading: false,
plan: { id: 1, holiday_calendars: [] },
selectedYear: 2026,
years: [2026],
selectYear: vi.fn((_year: number) => undefined),
monthsFor: (_year: number) => Array.from({ length: 12 }, (_, m) => ({ year: 2026, month: m })),
monthNameLongFor: (slot: number) => MONTHS_LONG[slot],
users: [
{ id: 1, username: 'alice', color: '#3b82f6' },
{ id: 2, username: 'bob', color: null },
@@ -218,19 +230,30 @@ describe('MVacay', () => {
expect(screen.queryByRole('button', { name: 'Increase allowance' })).not.toBeInTheDocument();
});
it('FE-MOB-MVACSCR-010: person chips select their member and fall back on the shared color', () => {
it('FE-MOB-MVACSCR-010: the people pill opens the people sheet, where a member is picked', () => {
render(<MVacay />);
const bobChip = screen.getByRole('button', { name: 'bob' });
const swatch = bobChip.querySelector('span') as HTMLElement;
expect(swatch.style.background).toBe('rgb(99, 102, 241)');
fireEvent.click(bobChip);
expect(mocks.v.selectPerson).toHaveBeenCalledWith(2);
fireEvent.click(screen.getByRole('button', { name: /People/ }));
expect(mocks.v.setSheet).toHaveBeenCalledWith('people');
});
it('FE-MOB-MVACSCR-011: shared chips toggle their overlay and report their state', () => {
it('FE-MOB-MVACSCR-010b: in the people sheet a member is picked, falling back on the shared color', async () => {
mocks.v = buildV({ sheet: 'people' });
render(<MVacay />);
const sheet = await screen.findByRole('dialog', { name: 'People' });
const bobRow = within(sheet).getByRole('button', { name: 'bob' });
const swatch = bobRow.querySelector('span') as HTMLElement;
expect(swatch.style.background).toBe('rgb(99, 102, 241)');
expect(within(sheet).getByRole('button', { name: 'alice' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(bobRow);
expect(mocks.v.selectPerson).toHaveBeenCalledWith(2);
expect(mocks.v.setSheet).toHaveBeenLastCalledWith(null);
});
it('FE-MOB-MVACSCR-011: shared calendars toggle their overlay from the people sheet and report their state', async () => {
mocks.v = buildV({
sheet: 'people',
incomingShares: [
{ id: 7, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false },
{ id: 8, owner_id: 4, username: 'dan', color: '#10b981', hidden: true },
@@ -238,8 +261,9 @@ describe('MVacay', () => {
});
render(<MVacay />);
const carol = screen.getByRole('button', { name: 'carol' });
const dan = screen.getByRole('button', { name: 'dan' });
const sheet = await screen.findByRole('dialog', { name: 'People' });
const carol = within(sheet).getByRole('button', { name: 'carol' });
const dan = within(sheet).getByRole('button', { name: 'dan' });
expect(carol).toHaveAttribute('aria-pressed', 'true');
expect(dan).toHaveAttribute('aria-pressed', 'false');
@@ -249,8 +273,14 @@ describe('MVacay', () => {
expect(mocks.v.toggleShareHidden).toHaveBeenNthCalledWith(2, 8, false);
});
it('FE-MOB-MVACSCR-012: shows the company legend and every enabled holiday calendar', () => {
mocks.v = buildV({
it('FE-MOB-MVACSCR-011b: a single member with nothing shared gets no people pill', () => {
mocks.v = buildV({ users: [{ id: 1, username: 'alice', color: '#3b82f6' }] });
render(<MVacay />);
expect(screen.queryByRole('button', { name: /People/ })).not.toBeInTheDocument();
});
it('FE-MOB-MVACSCR-012: the legend waits behind its pill and lists the company days and every holiday calendar', async () => {
const holidays = {
holidaysEnabled: true,
plan: {
id: 1,
@@ -259,12 +289,21 @@ describe('MVacay', () => {
{ id: 2, region: 'NL', label: null, color: '#bbf7d0' },
],
},
});
render(<MVacay />);
};
mocks.v = buildV(holidays);
const { unmount } = render(<MVacay />);
// Not on screen until asked for.
expect(screen.queryByText('Germany')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Legend/ }));
expect(mocks.v.setSheet).toHaveBeenCalledWith('legend');
unmount();
expect(screen.getByText('Company days')).toBeInTheDocument();
expect(screen.getByText('Germany')).toBeInTheDocument();
expect(screen.getByText('NL')).toBeInTheDocument();
mocks.v = buildV({ ...holidays, sheet: 'legend' });
render(<MVacay />);
const sheet = await screen.findByRole('dialog', { name: 'Legend' });
expect(within(sheet).getByText('Company days')).toBeInTheDocument();
expect(within(sheet).getByText('Germany')).toBeInTheDocument();
expect(within(sheet).getByText('NL')).toBeInTheDocument();
});
it('FE-MOB-MVACSCR-013: the year grid shows twelve month cards and reports day taps', () => {
@@ -315,7 +354,7 @@ describe('MVacay', () => {
mocks.v = buildV({ view: 'edit' });
render(<MVacay />);
// The person chip row carries the same name; the mode switch comes last.
// The mode switch carries the selected person's name.
const person = screen.getAllByRole('button', { name: 'alice' });
fireEvent.click(person[person.length - 1]);
expect(mocks.v.setMode).toHaveBeenLastCalledWith('vacation');
@@ -250,6 +250,19 @@ describe('useMVacay', () => {
expect(result.current.monthSlot).toBe(11);
});
it('FE-MOB-MVAC-012b: a swipe selects only years the plan has, and names any slot and any year', async () => {
const setSelectedYear = vi.fn((_year: number) => undefined);
mocks.vacay = buildVacay({ years: [2025, 2026], setSelectedYear });
const { result } = await mount();
act(() => { result.current.selectYear(2025); });
act(() => { result.current.selectYear(2031); });
expect(setSelectedYear.mock.calls).toEqual([[2025]]);
expect(result.current.monthsFor(2025)[0]).toEqual({ year: 2025, month: 0 });
expect(result.current.monthNameLongFor(0)).toMatch(/jan/i);
});
it('FE-MOB-MVAC-013: only a fused plan allows selecting another member', async () => {
const { result } = await mount();
+2
View File
@@ -24,6 +24,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'أيام الشركة',
'mobileVacay.editCalendar': 'تحرير التقويم',
'mobileVacay.viewYear': 'عرض السنة',
'mobileVacay.people': 'الأشخاص',
'mobileVacay.legend': 'المفتاح',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Şirkət günləri',
'mobileVacay.editCalendar': 'Təqvimi redaktə et',
'mobileVacay.viewYear': 'İlə bax',
'mobileVacay.people': 'İnsanlar',
'mobileVacay.legend': 'İzahat',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Férias coletivas',
'mobileVacay.editCalendar': 'Editar calendário',
'mobileVacay.viewYear': 'Ver ano',
'mobileVacay.people': 'Pessoas',
'mobileVacay.legend': 'Legenda',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': "Dies d'empresa",
'mobileVacay.editCalendar': 'Edita el calendari',
'mobileVacay.viewYear': "Mostra l'any",
'mobileVacay.people': 'Persones',
'mobileVacay.legend': 'Llegenda',
};
export default mobileVacay;
+2
View File
@@ -18,6 +18,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Firemní dny',
'mobileVacay.editCalendar': 'Upravit kalendář',
'mobileVacay.viewYear': 'Zobrazit rok',
'mobileVacay.people': 'Lidé',
'mobileVacay.legend': 'Legenda',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Betriebsferien',
'mobileVacay.editCalendar': 'Kalender bearbeiten',
'mobileVacay.viewYear': 'Jahr ansehen',
'mobileVacay.people': 'Personen',
'mobileVacay.legend': 'Legende',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Company days',
'mobileVacay.editCalendar': 'Edit calendar',
'mobileVacay.viewYear': 'View year',
'mobileVacay.people': 'People',
'mobileVacay.legend': 'Legend',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Días de empresa',
'mobileVacay.editCalendar': 'Editar calendario',
'mobileVacay.viewYear': 'Ver año',
'mobileVacay.people': 'Personas',
'mobileVacay.legend': 'Leyenda',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Ettevõtte puhkepäevad',
'mobileVacay.editCalendar': 'Muuda kalendrit',
'mobileVacay.viewYear': 'Vaata aastat',
'mobileVacay.people': 'Inimesed',
'mobileVacay.legend': 'Legend', // same-as-en
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': "Congés d'entreprise",
'mobileVacay.editCalendar': 'Modifier le calendrier',
'mobileVacay.viewYear': "Voir l'année",
'mobileVacay.people': 'Personnes',
'mobileVacay.legend': 'Légende',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Άδεια εταιρείας',
'mobileVacay.editCalendar': 'Επεξεργασία ημερολογίου',
'mobileVacay.viewYear': 'Προβολή έτους',
'mobileVacay.people': 'Άτομα',
'mobileVacay.legend': 'Υπόμνημα',
};
export default mobileVacay;
+2
View File
@@ -18,6 +18,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'ימי חברה',
'mobileVacay.editCalendar': 'עריכת לוח שנה',
'mobileVacay.viewYear': 'תצוגת שנה',
'mobileVacay.people': 'אנשים',
'mobileVacay.legend': 'מקרא',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Céges szabadság',
'mobileVacay.editCalendar': 'Naptár szerkesztése',
'mobileVacay.viewYear': 'Év megtekintése',
'mobileVacay.people': 'Személyek',
'mobileVacay.legend': 'Jelmagyarázat',
};
export default mobileVacay;
+2
View File
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Libur perusahaan',
'mobileVacay.editCalendar': 'Edit kalender',
'mobileVacay.viewYear': 'Lihat tahun',
'mobileVacay.people': 'Orang',
'mobileVacay.legend': 'Keterangan',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Ferie aziendali',
'mobileVacay.editCalendar': 'Modifica calendario',
'mobileVacay.viewYear': 'Visualizza anno',
'mobileVacay.people': 'Persone',
'mobileVacay.legend': 'Legenda',
};
export default mobileVacay;
+2
View File
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': '会社の休業日',
'mobileVacay.editCalendar': 'カレンダーを編集',
'mobileVacay.viewYear': '年間を表示',
'mobileVacay.people': 'メンバー',
'mobileVacay.legend': '凡例',
};
export default mobileVacay;
+2
View File
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': '회사 지정 휴무',
'mobileVacay.editCalendar': '캘린더 편집',
'mobileVacay.viewYear': '연간 보기',
'mobileVacay.people': '사람',
'mobileVacay.legend': '범례',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Bedrijfsverlof',
'mobileVacay.editCalendar': 'Kalender bewerken',
'mobileVacay.viewYear': 'Jaar bekijken',
'mobileVacay.people': 'Personen',
'mobileVacay.legend': 'Legenda',
};
export default mobileVacay;
+2
View File
@@ -20,6 +20,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Dni firmowe',
'mobileVacay.editCalendar': 'Edytuj kalendarz',
'mobileVacay.viewYear': 'Zobacz rok',
'mobileVacay.people': 'Osoby',
'mobileVacay.legend': 'Legenda',
};
export default mobileVacay;
+2
View File
@@ -20,6 +20,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Корпоративные дни',
'mobileVacay.editCalendar': 'Изменить календарь',
'mobileVacay.viewYear': 'Просмотр года',
'mobileVacay.people': 'Люди',
'mobileVacay.legend': 'Легенда',
};
export default mobileVacay;
+2
View File
@@ -18,6 +18,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Firemné dni',
'mobileVacay.editCalendar': 'Upraviť kalendár',
'mobileVacay.viewYear': 'Zobraziť rok',
'mobileVacay.people': 'Ľudia',
'mobileVacay.legend': 'Legenda',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Företagsledighet',
'mobileVacay.editCalendar': 'Redigera kalender',
'mobileVacay.viewYear': 'Visa år',
'mobileVacay.people': 'Personer',
'mobileVacay.legend': 'Teckenförklaring',
};
export default mobileVacay;
+2
View File
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'วันของบริษัท',
'mobileVacay.editCalendar': 'แก้ไขปฏิทิน',
'mobileVacay.viewYear': 'ดูปี',
'mobileVacay.people': 'บุคคล',
'mobileVacay.legend': 'คำอธิบายสัญลักษณ์',
};
export default mobileVacay;
+2
View File
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Şirket tatili',
'mobileVacay.editCalendar': 'Takvimi düzenle',
'mobileVacay.viewYear': 'Yılı görüntüle',
'mobileVacay.people': 'Kişiler',
'mobileVacay.legend': 'Açıklama',
};
export default mobileVacay;
+2
View File
@@ -20,6 +20,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Корпоративні дні',
'mobileVacay.editCalendar': 'Редагувати календар',
'mobileVacay.viewYear': 'Переглянути рік',
'mobileVacay.people': 'Люди',
'mobileVacay.legend': 'Легенда',
};
export default mobileVacay;
+2
View File
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': 'Ngày nghỉ công ty',
'mobileVacay.editCalendar': 'Sửa lịch',
'mobileVacay.viewYear': 'Xem năm',
'mobileVacay.people': 'Mọi người',
'mobileVacay.legend': 'Chú thích',
};
export default mobileVacay;
+2
View File
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': '公司假期',
'mobileVacay.editCalendar': '編輯日曆',
'mobileVacay.viewYear': '檢視年度',
'mobileVacay.people': '成員',
'mobileVacay.legend': '圖例',
};
export default mobileVacay;
+2
View File
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.companyLegend': '公司假期',
'mobileVacay.editCalendar': '编辑日历',
'mobileVacay.viewYear': '查看年度',
'mobileVacay.people': '成员',
'mobileVacay.legend': '图例',
};
export default mobileVacay;