From c0f75b9a5e7a01d69318ce03caf95e9b2e552aca Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 15:50:55 +0200 Subject: [PATCH] feat(assistant): vacation calendar cards with the days left --- .../cards/vacay/VacayAnswerBlock.tsx | 91 +++ .../Assistant/cards/vacay/VacayBalance.tsx | 184 ++++++ .../Assistant/cards/vacay/VacayCard.test.tsx | 215 +++++++ .../cards/vacay/VacayChangeGroup.tsx | 85 +++ .../Assistant/cards/vacay/VacayMiniMonth.tsx | 124 ++++ .../Assistant/cards/vacay/VacayMonthStrip.tsx | 91 +++ .../Assistant/cards/vacay/VacayRowFacts.tsx | 77 +++ .../cards/vacay/vacayCardModel.test.ts | 227 +++++++ .../Assistant/cards/vacay/vacayCardModel.ts | 284 +++++++++ .../Assistant/cards/vacay/vacayCellLook.ts | 115 ++++ .../Assistant/cards/vacay/vacayLook.ts | 67 ++- .../Assistant/cards/vacay/vacayViews.tsx | 21 +- .../src/mobile/screens/vacay/MVacayMonth.tsx | 3 +- client/src/mobile/screens/vacay/useMVacay.ts | 3 +- .../src/mobile/screens/vacay/vacayDayModel.ts | 9 - client/src/pages/VacayPage.test.tsx | 11 + client/src/pages/vacay/useVacay.ts | 12 +- client/src/store/vacayStore.test.ts | 15 +- client/src/store/vacayStore.ts | 15 +- client/src/vacay/monthGrid.ts | 15 + .../unit/mobile/vacay/vacayDayModel.test.ts | 4 +- .../src/nest/assistant/cards/vacay/index.ts | 60 +- .../assistant/cards/vacay/vacay-answer.ts | 128 ++++ .../assistant/cards/vacay/vacay-applied.ts | 103 ++++ .../assistant/cards/vacay/vacay-change.ts | 250 ++++++++ .../nest/assistant/cards/vacay/vacay-frame.ts | 150 +++++ .../nest/assistant/cards/vacay/vacay-load.ts | 116 ++++ .../nest/assistant/cards/vacay/vacay-reads.ts | 206 +++++++ .../assistant/cards/vacay/vacay-cards.test.ts | 560 ++++++++++++++++++ shared/src/assistant/cards/vacay.card.spec.ts | 88 +++ shared/src/assistant/cards/vacay.card.ts | 127 +++- shared/src/assistant/cards/vacay.contract.ts | 12 +- shared/src/i18n/ar/assistantVacay.ts | 78 ++- shared/src/i18n/az/assistantVacay.ts | 50 +- shared/src/i18n/br/assistantVacay.ts | 50 +- shared/src/i18n/ca/assistantVacay.ts | 50 +- shared/src/i18n/cs/assistantVacay.ts | 57 +- shared/src/i18n/de/assistantVacay.ts | 50 +- shared/src/i18n/en/assistantVacay.ts | 50 +- shared/src/i18n/es/assistantVacay.ts | 50 +- shared/src/i18n/et/assistantVacay.ts | 50 +- shared/src/i18n/fr/assistantVacay.ts | 50 +- shared/src/i18n/gr/assistantVacay.ts | 50 +- shared/src/i18n/hu/assistantVacay.ts | 50 +- shared/src/i18n/id/assistantVacay.ts | 43 +- shared/src/i18n/it/assistantVacay.ts | 50 +- shared/src/i18n/ja/assistantVacay.ts | 43 +- shared/src/i18n/ko/assistantVacay.ts | 43 +- shared/src/i18n/nl/assistantVacay.ts | 50 +- shared/src/i18n/pl/assistantVacay.ts | 64 +- shared/src/i18n/ru/assistantVacay.ts | 64 +- shared/src/i18n/sk/assistantVacay.ts | 57 +- shared/src/i18n/sv/assistantVacay.ts | 50 +- shared/src/i18n/th/assistantVacay.ts | 43 +- shared/src/i18n/tr/assistantVacay.ts | 50 +- shared/src/i18n/uk/assistantVacay.ts | 64 +- shared/src/i18n/vi/assistantVacay.ts | 43 +- shared/src/i18n/zh-TW/assistantVacay.ts | 43 +- shared/src/i18n/zh/assistantVacay.ts | 43 +- 59 files changed, 4783 insertions(+), 70 deletions(-) create mode 100644 client/src/components/Assistant/cards/vacay/VacayAnswerBlock.tsx create mode 100644 client/src/components/Assistant/cards/vacay/VacayBalance.tsx create mode 100644 client/src/components/Assistant/cards/vacay/VacayCard.test.tsx create mode 100644 client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx create mode 100644 client/src/components/Assistant/cards/vacay/VacayMiniMonth.tsx create mode 100644 client/src/components/Assistant/cards/vacay/VacayMonthStrip.tsx create mode 100644 client/src/components/Assistant/cards/vacay/VacayRowFacts.tsx create mode 100644 client/src/components/Assistant/cards/vacay/vacayCardModel.test.ts create mode 100644 client/src/components/Assistant/cards/vacay/vacayCardModel.ts create mode 100644 client/src/components/Assistant/cards/vacay/vacayCellLook.ts create mode 100644 client/src/vacay/monthGrid.ts create mode 100644 server/src/nest/assistant/cards/vacay/vacay-answer.ts create mode 100644 server/src/nest/assistant/cards/vacay/vacay-applied.ts create mode 100644 server/src/nest/assistant/cards/vacay/vacay-change.ts create mode 100644 server/src/nest/assistant/cards/vacay/vacay-frame.ts create mode 100644 server/src/nest/assistant/cards/vacay/vacay-load.ts create mode 100644 server/src/nest/assistant/cards/vacay/vacay-reads.ts create mode 100644 server/tests/unit/nest/assistant/cards/vacay/vacay-cards.test.ts create mode 100644 shared/src/assistant/cards/vacay.card.spec.ts diff --git a/client/src/components/Assistant/cards/vacay/VacayAnswerBlock.tsx b/client/src/components/Assistant/cards/vacay/VacayAnswerBlock.tsx new file mode 100644 index 000000000..9fe5aca3b --- /dev/null +++ b/client/src/components/Assistant/cards/vacay/VacayAnswerBlock.tsx @@ -0,0 +1,91 @@ +import type { CSSProperties } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import { safeHexColor } from '../../../../utils/safeColor'; +import type { CardEntry } from '../cardTypes'; +import CardFrame from '../parts/CardFrame'; +import { CardOpenButton } from '../parts/CardOpen'; +import { useViewerId } from '../parts/useViewerId'; +import VacayBalance from './VacayBalance'; +import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel'; +import { personColor } from './vacayCellLook'; +import { vacayPathOf } from './vacayLook'; +import VacayMonthStrip from './VacayMonthStrip'; + +/** + * What the vacation planner's reads found, as one card: the months with the + * days off, a legend once more than one thing is drawn, the days off outside + * the months shown, the days left per person, and the way into Vacay on that + * year. Several reads in one answer read as one calendar. + */ + +const LEGEND = 'mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-content-muted'; +const LEGEND_ITEM = 'inline-flex min-w-0 items-center gap-1.5'; +const SWATCH = 'size-2 flex-none rounded-full'; +const SQUARE = 'size-2 flex-none rounded-sm'; +const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning'; +const TRIP = 'size-1.5 flex-none rounded-full bg-info'; +const OTHER = 'mt-1.5 text-caption text-content-muted'; +const FOOT = 'mt-1 flex justify-end'; + +interface LegendItem { + key: string; + label: string; + className: string; + style?: CSSProperties; +} + +/** What the calendar draws, named once it draws more than one thing. */ +function Legend({ view }: { view: VacayView }) { + const { t } = useTranslation(); + const viewerId = useViewerId(); + const people = view.people.filter((person) => view.days.some((day) => day.personId === person.id)); + const holiday = view.holidays[0]; + const items: LegendItem[] = people.map((person) => ({ + key: `person-${person.id}`, + label: person.id === viewerId ? t('assistant.card.common.you') : person.name, + className: SWATCH, + style: { background: personColor(view, person.id) }, + })); + if (holiday) { + const color = safeHexColor(holiday.color, 'var(--danger)'); + items.push({ + key: 'holiday', + label: t('vacay.publicHoliday'), + className: SQUARE, + style: { background: `color-mix(in srgb, ${color} 28%, transparent)` }, + }); + } + if (view.company.length > 0) items.push({ key: 'company', label: t('vacay.companyHoliday'), className: COMPANY }); + if (view.tripDates.size > 0) items.push({ key: 'trip', label: t('assistant.card.vacay.trip'), className: TRIP }); + if (items.length < 2) return null; + return ( + + ); +} + +export default function VacayAnswerBlock({ cards }: { cards: readonly CardEntry[] }) { + const { t } = useTranslation(); + const own = cards.flatMap((entry) => (entry.card.kind === 'vacay' ? [entry.card] : [])); + const view = mergeVacayCards(own); + const lines = balanceLines(view, []); + const first = own[0] ?? null; + return ( + + {view.months.length > 0 && ''} />} + + {view.otherDays > 0 &&

{t('assistant.card.vacay.otherDays', { count: view.otherDays })}

} + +
+ +
+
+ ); +} diff --git a/client/src/components/Assistant/cards/vacay/VacayBalance.tsx b/client/src/components/Assistant/cards/vacay/VacayBalance.tsx new file mode 100644 index 000000000..d990b9de1 --- /dev/null +++ b/client/src/components/Assistant/cards/vacay/VacayBalance.tsx @@ -0,0 +1,184 @@ +import { ArrowRight, CalendarDays, TriangleAlert } from 'lucide-react'; +import type { CSSProperties, ReactNode } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill, WhiteBadge } from '../../../Planner/planParts'; +import { Tooltip } from '../../../shared/Tooltip'; +import { fmtDays } from '../../../Vacay/vacayFormat'; +import { useViewerId } from '../parts/useViewerId'; +import { type BalanceLine, lineFigures, namesYear, todayStr, type VacayView } from './vacayCardModel'; +import { personColor } from './vacayCellLook'; + +/** + * The days left per person and leave year under the calendar: who, the days + * left before and after the changes that count, a meter of used, planned and + * left days in the person's colour, and what the changes come to (days used + * or given back, past the allowance, a new allowance). A read card shows the + * days left as they are. + */ + +const ROW = 'mt-2 first:mt-1.5'; +const HEAD = 'flex min-w-0 items-center gap-2'; +const DOT = 'size-2 flex-none rounded-full'; +const NAME = 'min-w-0 flex-1 text-body text-content'; +const FIGURES = 'flex flex-none items-baseline gap-1 tabular-nums'; +const FIGURE = 'text-body font-semibold text-content'; +const CAPTION = 'text-caption text-content-muted'; +const METER = 'mt-1 flex h-1.5 gap-0.5 overflow-hidden rounded-full'; +const PILLS = 'mt-1 flex flex-wrap items-center gap-1'; +const ARROW = 'flex-none self-center text-content-faint rtl:-scale-x-100'; + +type Mode = 'read' | 'change'; + +interface Piece { + key: string; + grow: number; + style: CSSProperties; + label: string; + className?: string; +} + +/** The pieces of a person's meter: used, given back, planned, past the allowance, left. */ +function meterPieces(line: BalanceLine, color: string, t: (key: string, params?: Record) => string) { + const total = line.totalAfter ?? line.total; + const planned = Math.max(0, line.delta); + const given = Math.max(0, -line.delta); + const over = Math.max(0, line.used + line.delta - total); + const left = Math.max(0, total - line.used - line.delta); + const hatch = (tone: string) => `repeating-linear-gradient(45deg, ${tone} 0 2px, transparent 2px 4px)`; + const pieces: Piece[] = [ + { + key: 'used', + grow: line.used - given, + style: { background: `color-mix(in srgb, ${color} 78%, transparent)` }, + label: t('assistant.card.vacay.usedDays', { count: line.used - given }), + }, + { + key: 'given', + grow: given, + style: {}, + label: t('assistant.card.vacay.givesBack', { count: given }), + className: 'rounded-full outline-dashed outline-1 outline-offset-[-1px] outline-edge', + }, + { + key: 'planned', + grow: planned - over, + style: { background: hatch(`color-mix(in srgb, ${color} 78%, transparent)`) }, + label: t('assistant.card.vacay.uses', { count: planned - over }), + }, + { + key: 'over', + grow: over, + style: { background: `${hatch('var(--danger)')}, var(--danger-soft)` }, + label: t('assistant.card.vacay.over', { count: over }), + }, + { + key: 'left', + grow: left, + style: { background: `color-mix(in srgb, ${color} 16%, transparent)` }, + label: t('assistant.card.vacay.daysLeft', { count: left }), + }, + ]; + return pieces.filter((piece) => piece.grow > 0); +} + +function Line({ line, view, mode, withYear }: { line: BalanceLine; view: VacayView; mode: Mode; withYear: boolean }) { + const { t } = useTranslation(); + const viewerId = useViewerId(); + const person = view.people.find((candidate) => candidate.id === line.personId); + const color = personColor(view, line.personId); + const { left, leftAfter, over } = lineFigures(line); + const changes = line.delta !== 0 || line.totalAfter !== null; + const total = line.totalAfter ?? line.total; + const name = line.personId === viewerId ? t('assistant.card.common.you') : (person?.name ?? `#${line.personId}`); + const usedOf = t('assistant.card.vacay.usedOf', { used: fmtDays(line.used), total: fmtDays(total) }); + const meter = t('assistant.card.vacay.meter', { + used: fmtDays(line.used), + planned: fmtDays(Math.max(0, line.delta)), + left: fmtDays(Math.max(0, leftAfter)), + total: fmtDays(total), + }); + return ( +
  • +
    +
    +
    + {meterPieces(line, color, t).map((piece) => ( + + + + ))} +
    + +
  • + ); +} + +/** What the changes come to, in pills; a read card names the comp days only. */ +function BalanceFacts({ line, mode, over }: { line: BalanceLine; mode: Mode; over: number }) { + const { t } = useTranslation(); + const pills: ReactNode[] = []; + if (line.delta > 0) { + pills.push({t('assistant.card.vacay.uses', { count: line.delta })}); + } + if (line.delta < 0) { + pills.push({t('assistant.card.vacay.givesBack', { count: -line.delta })}); + } + if (over > 0) { + pills.push( + + ); + } + if (line.totalAfter !== null) { + // The allowance alone, as the user set it: the totals also hold what was carried over. + pills.push( + + ); + } + if (mode === 'read' && line.comp > 0) { + pills.push({t('assistant.card.vacay.compDays', { count: line.comp })}); + } + return pills.length > 0 ?
    {pills}
    : null; +} + +export default function VacayBalance({ + lines, + view, + mode, +}: { + lines: readonly BalanceLine[]; + view: VacayView; + mode: Mode; +}) { + if (lines.length === 0) return null; + const withYear = namesYear(lines, todayStr()); + return ( + + ); +} diff --git a/client/src/components/Assistant/cards/vacay/VacayCard.test.tsx b/client/src/components/Assistant/cards/vacay/VacayCard.test.tsx new file mode 100644 index 000000000..8ec8bbd2b --- /dev/null +++ b/client/src/components/Assistant/cards/vacay/VacayCard.test.tsx @@ -0,0 +1,215 @@ +/** + * FE-AI-VACAY-010 to FE-AI-VACAY-015 + * + * The vacation card as the chat draws it: an answer as a month calendar with + * the days left and the way into Vacay, a change card's vacation rows under + * one calendar whose held days are taken in or left out with a tap, the days + * left following the choice, each row's facts, a decided card, and the phone, + * where one month fills the row. + */ +import type { AssistantApplied, AssistantChangeVerbKey, VacayCard, VacayCardProposal } from '@trek/shared'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildUser } from '../../../../../tests/helpers/factories'; +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { chatOf, useAssistantStore } from '../../../../store/assistantStore'; +import { useAuthStore } from '../../../../store/authStore'; +import AssistantChangeCard from '../../AssistantChangeCard'; +import type { ChangeItem } from '../../assistantParts'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import VacayAnswerBlock from './VacayAnswerBlock'; +import VacayRowFacts from './VacayRowFacts'; + +const TRIP = 4; +const AUTH = useAuthStore.getState(); +const INITIAL = useAssistantStore.getState(); + +const proposal = (over: Partial = {}): VacayCardProposal => ({ + date: '2026-10-27', + personId: 7, + kind: 'vacation', + fraction: 1, + outcome: 'add', + before: null, + periodYear: 2026, + notes: [], + deltas: [{ personId: 7, year: 2026, days: 1 }], + ...over, +}); + +const card = (over: Partial = {}): VacayCard => ({ + kind: 'vacay', + months: [{ year: 2026, month: 10 }], + weekStart: 1, + weekendDays: [0, 6], + people: [ + { id: 7, name: 'Ana', avatarKey: null, color: '#6366f1' }, + { id: 8, name: 'Ben', avatarKey: null, color: '#ec4899' }, + ], + days: [ + { date: '2026-10-26', personId: 7, fraction: 1, kind: 'vacation' }, + { date: '2026-10-29', personId: 8, fraction: 1, kind: 'vacation' }, + ], + company: [], + holidays: [{ date: '2026-10-03', name: 'Unity Day', color: null }], + tripDates: ['2026-10-28'], + proposal: null, + balances: [ + { + personId: 7, + year: 2026, + total: 30, + used: 29, + comp: 0, + totalAfter: null, + windowStart: '2026-01-01', + windowEnd: '2027-01-01', + }, + ], + otherDays: 0, + ...over, +}); + +function row( + id: string, + verbKey: AssistantChangeVerbKey, + held: VacayCardProposal, + state: ChangeItem['state'] = 'pending' +): ChangeItem { + const applied: AssistantApplied | null = + state === 'applied' ? { toolCallId: id, entity: 'vacay', ids: [], dayId: null, undoUntil: null } : null; + return { + toolCallId: id, + preview: { toolCallId: id, verbKey, subject: held.date, rows: [], dayId: null, placeIds: [], undoable: false }, + applied, + state, + card: card({ proposal: held }), + }; +} + +function inView(ui: ReactElement, view: Partial = {}) { + const value: AssistantView = { tripId: TRIP, variant: 'window', ...view }; + return render({ui}); +} + +const decide = vi.fn(async () => {}); + +beforeEach(() => { + useAuthStore.setState({ user: buildUser({ id: 7 }) }); + useAssistantStore.setState({ + decide, + byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } }, + }); +}); + +afterEach(() => { + useAuthStore.setState(AUTH, true); + useAssistantStore.setState(INITIAL, true); + vi.clearAllMocks(); +}); + +describe('the vacation card', () => { + it('FE-AI-VACAY-010: an answer shows its month, a legend, the days left and the way into Vacay on its year', () => { + const onOpen = vi.fn(); + inView(, { onOpen }); + expect(screen.getByRole('group', { name: 'October 2026' })).toBeInTheDocument(); + expect(screen.getAllByText('You').length).toBeGreaterThan(1); + for (const label of ['Ben', 'Public Holiday', 'This trip', '2 more days off this year', '29 of 30 used']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(screen.getByText('1 day left')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: '29 used, 0 planned, 1 left of 30' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Open in Vacay/ })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/vacay?year=2026' }); + }); + + it('FE-AI-VACAY-011: held days sit on the calendar; a free one starts left out and a tap takes it in', () => { + inView( + + ); + const taken = screen.getByRole('button', { name: 'Tue, Oct 27: Take a day off' }); + const free = screen.getByRole('button', { name: 'Sat, Oct 3: Take a day off' }); + expect(taken).toHaveAttribute('aria-pressed', 'true'); + expect(free).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Mon, Oct 26: Cancel a day off' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Apply 2' })).toBeEnabled(); + expect(screen.getByText('Public holiday, no leave needed')).toBeInTheDocument(); + + fireEvent.click(free); + expect(screen.getByRole('button', { name: 'Sat, Oct 3: Take a day off' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Apply 3' })); + expect(decide).toHaveBeenCalledWith(TRIP, [ + { toolCallId: 'c1', approved: true }, + { toolCallId: 'c2', approved: true }, + { toolCallId: 'c3', approved: true }, + ]); + }); + + it('FE-AI-VACAY-012: the days left follow the choice, up to a warning past the allowance', () => { + inView( + + ); + expect(screen.getByText('Uses 2 days')).toBeInTheDocument(); + expect(screen.getByText('Over by 1 day')).toBeInTheDocument(); + expect(screen.getByText('0 days left')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Wed, Oct 28: Take a day off' })); + expect(screen.queryByText('Over by 1 day')).toBeNull(); + expect(screen.getByText('Uses 1 day')).toBeInTheDocument(); + }); + + it("FE-AI-VACAY-013: a row says how much of the day, whose day when it is not the user's, and a new allowance", () => { + const halfForBen = proposal({ + personId: 8, + outcome: 'convert', + fraction: 0.5, + before: { fraction: 1, kind: 'vacation' }, + notes: ['weekend'], + }); + const item = row('c1', 'changeVacationDay', halfForBen); + const view = inView(); + expect(screen.getByText('Full day')).toHaveClass('line-through'); + expect(screen.getByText('Half day')).toBeInTheDocument(); + expect(screen.getByText('Ben')).toBeInTheDocument(); + expect(screen.getByText('Weekend day, counts as leave')).toBeInTheDocument(); + view.unmount(); + + // A company holiday while they are off in the plan: the tool writes it anyway, the row warns. + const off = proposal({ personId: null, kind: 'company', deltas: [], notes: ['companyOff'] }); + const company = inView(); + expect(screen.getByText('Company holidays are turned off in this plan')).toBeInTheDocument(); + company.unmount(); + + // The allowance alone: the totals 30 and 33 both hold the 2 days carried over. + const balance = { ...card().balances[0], carried: 2, totalAfter: 33 }; + const allowance = card({ months: [], balances: [balance] }); + inView(); + expect(screen.getByText('28')).toHaveClass('line-through'); + expect(screen.getByText('31')).toBeInTheDocument(); + }); + + it('FE-AI-VACAY-014: a decided card shows what was applied, without taps, and its receipt opens Vacay', () => { + const onOpen = vi.fn(); + inView(, { onOpen }); + expect(screen.queryByRole('button', { name: /Take a day off/ })).toBeNull(); + expect(screen.getByText('0 days left')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open in Vacay' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/vacay?year=2026' }); + }); + + it('FE-AI-VACAY-015: on the phone one month fills the row and a cell takes a finger', () => { + const { container } = inView(, { + variant: 'fullscreen', + }); + expect(container.querySelector('li.w-full')).not.toBeNull(); + expect(screen.getByRole('button', { name: 'Tue, Oct 27: Take a day off' })).toHaveClass('size-9'); + }); +}); diff --git a/client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx b/client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx new file mode 100644 index 000000000..8601f4c48 --- /dev/null +++ b/client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx @@ -0,0 +1,85 @@ +import { TriangleAlert } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { chatOf, useAssistantStore } from '../../../../store/assistantStore'; +import { SoftPill } from '../../../Planner/planParts'; +import type { ChangeItem } from '../../assistantParts'; +import { useAssistantView } from '../../assistantViewContext'; +import { verbLookOf } from '../cardLooks'; +import VacayBalance from './VacayBalance'; +import { + allowancesOf, + balanceLines, + mergeVacayCards, + type PlacedProposal, + placedProposals, + vacayCardsOf, +} from './vacayCardModel'; +import VacayMonthStrip from './VacayMonthStrip'; + +/** + * The calendar over a change card's vacation rows: the months of the held + * days with what is logged there, each held day on its cell (tap one to take + * it in or leave it out), and the days left before and after what is chosen. + * Once the changes are decided it shows what was applied. + */ + +const GROUP = 'mb-1 rounded-lg bg-surface-secondary px-2.5 pb-2 pt-2'; +const PILLS = 'mt-1.5 flex flex-wrap items-center gap-1'; + +/** A held day's date as the change rows write it. */ +function dateLabel(locale: string, date: string): string { + const value = new Date(`${date}T00:00:00`); + return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(value); +} + +export default function VacayChangeGroup({ + items, + excluded, + onToggle, + place, +}: { + items: readonly ChangeItem[]; + excluded: ReadonlySet; + onToggle(id: string): void; + place: 'head' | 'foot'; +}) { + const { t, locale } = useTranslation(); + const { tripId } = useAssistantView(); + const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning); + if (place !== 'head') return null; + const view = mergeVacayCards(vacayCardsOf(items).map((entry) => entry.card)); + const placed = placedProposals(items, excluded); + const lines = balanceLines(view, placed, allowancesOf(items, excluded)); + const waiting = items.some((item) => item.state === 'pending'); + const verbOf = (toolCallId: string) => items.find((item) => item.toolCallId === toolCallId)?.preview?.verbKey; + const labelOf = (entry: PlacedProposal) => { + const verb = verbOf(entry.toolCallId); + const date = dateLabel(locale, entry.proposal.date); + return verb ? `${date}: ${t(verbLookOf(verb).labelKey)}` : date; + }; + const twice = placed.some((entry) => entry.twice); + return ( +
    + {view.months.length > 0 && ( + + )} + {placed.length > 0 && ( +
      {placed.map((entry) =>
    • {labelOf(entry)}
    • )}
    + )} + {twice && ( +
    + +
    + )} + +
    + ); +} diff --git a/client/src/components/Assistant/cards/vacay/VacayMiniMonth.tsx b/client/src/components/Assistant/cards/vacay/VacayMiniMonth.tsx new file mode 100644 index 000000000..e5053b7a9 --- /dev/null +++ b/client/src/components/Assistant/cards/vacay/VacayMiniMonth.tsx @@ -0,0 +1,124 @@ +import { useMemo } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import { Tooltip } from '../../../shared/Tooltip'; +import { + type CardMonth, + type CellSize, + type DayCell, + monthCells, + type PlacedProposal, + type VacayView, +} from './vacayCardModel'; +import { cellLook } from './vacayCellLook'; + +/** + * One month of the vacation card, drawn the way the planner draws it: the + * weekdays in the user's language, a person's days in their colour, the + * company's and the public holidays, the trip's days dotted. A held day sits + * on its cell; while it waits, the cell is a button that takes it in or + * leaves it out, and its tooltip says which. + */ + +const SIZE: Record = { compact: 'size-6', roomy: 'size-7', phone: 'size-9' }; +const GRID: Record = { + compact: 'grid grid-cols-7 gap-0.5', + roomy: 'grid grid-cols-7 gap-1', + phone: 'grid grid-cols-7 gap-1.5', +}; +const TITLE = 'mb-1.5 ps-0.5 text-caption font-semibold capitalize text-content'; +const WEEKDAY = 'grid h-5 place-items-center text-caption text-content-faint'; +const TRIP_DOT = 'absolute end-0.5 top-0.5 size-1 rounded-full bg-info'; +const HALF_DOT = 'absolute bottom-0.5 end-0.5 size-1 rounded-full bg-warning'; + +interface MonthProps { + view: VacayView; + month: CardMonth; + placed: readonly PlacedProposal[]; + today: string; + size: CellSize; + /** Takes a held day in or leaves it out; absent once the card is decided or while a turn runs. */ + onToggle?: (toolCallId: string) => void; + /** A held day in words: its date and what it does. */ + labelOf: (entry: PlacedProposal) => string; +} + +/** The narrow weekday names from the week's first day on; 7 January 2024 was a Sunday. */ +function weekdayNames(locale: string, weekStart: number): string[] { + const format = new Intl.DateTimeFormat(locale, { weekday: 'narrow' }); + return Array.from({ length: 7 }, (_, column) => format.format(new Date(2024, 0, 7 + ((column + weekStart) % 7)))); +} + +/** "October 2026" in the user's language. */ +function monthTitle(locale: string, year: number, month: number): string { + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(year, month - 1)); +} + +function Cell({ + view, + cell, + size, + onToggle, + labelOf, +}: Pick & { cell: DayCell }) { + const { t } = useTranslation(); + const look = cellLook(view, cell); + const className = [look.className, SIZE[size]].join(' '); + const marks = ( + <> + {cell.trip &&