mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 }: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': 'أيام الشركة',
|
||||
'mobileVacay.editCalendar': 'تحرير التقويم',
|
||||
'mobileVacay.viewYear': 'عرض السنة',
|
||||
'mobileVacay.people': 'الأشخاص',
|
||||
'mobileVacay.legend': 'المفتاح',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': 'Άδεια εταιρείας',
|
||||
'mobileVacay.editCalendar': 'Επεξεργασία ημερολογίου',
|
||||
'mobileVacay.viewYear': 'Προβολή έτους',
|
||||
'mobileVacay.people': 'Άτομα',
|
||||
'mobileVacay.legend': 'Υπόμνημα',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -18,6 +18,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': 'ימי חברה',
|
||||
'mobileVacay.editCalendar': 'עריכת לוח שנה',
|
||||
'mobileVacay.viewYear': 'תצוגת שנה',
|
||||
'mobileVacay.people': 'אנשים',
|
||||
'mobileVacay.legend': 'מקרא',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': '会社の休業日',
|
||||
'mobileVacay.editCalendar': 'カレンダーを編集',
|
||||
'mobileVacay.viewYear': '年間を表示',
|
||||
'mobileVacay.people': 'メンバー',
|
||||
'mobileVacay.legend': '凡例',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': '회사 지정 휴무',
|
||||
'mobileVacay.editCalendar': '캘린더 편집',
|
||||
'mobileVacay.viewYear': '연간 보기',
|
||||
'mobileVacay.people': '사람',
|
||||
'mobileVacay.legend': '범례',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': 'Корпоративные дни',
|
||||
'mobileVacay.editCalendar': 'Изменить календарь',
|
||||
'mobileVacay.viewYear': 'Просмотр года',
|
||||
'mobileVacay.people': 'Люди',
|
||||
'mobileVacay.legend': 'Легенда',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': 'วันของบริษัท',
|
||||
'mobileVacay.editCalendar': 'แก้ไขปฏิทิน',
|
||||
'mobileVacay.viewYear': 'ดูปี',
|
||||
'mobileVacay.people': 'บุคคล',
|
||||
'mobileVacay.legend': 'คำอธิบายสัญลักษณ์',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': 'Корпоративні дні',
|
||||
'mobileVacay.editCalendar': 'Редагувати календар',
|
||||
'mobileVacay.viewYear': 'Переглянути рік',
|
||||
'mobileVacay.people': 'Люди',
|
||||
'mobileVacay.legend': 'Легенда',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': '公司假期',
|
||||
'mobileVacay.editCalendar': '編輯日曆',
|
||||
'mobileVacay.viewYear': '檢視年度',
|
||||
'mobileVacay.people': '成員',
|
||||
'mobileVacay.legend': '圖例',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
@@ -14,6 +14,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.companyLegend': '公司假期',
|
||||
'mobileVacay.editCalendar': '编辑日历',
|
||||
'mobileVacay.viewYear': '查看年度',
|
||||
'mobileVacay.people': '成员',
|
||||
'mobileVacay.legend': '图例',
|
||||
};
|
||||
|
||||
export default mobileVacay;
|
||||
|
||||
Reference in New Issue
Block a user