+ );
+}
diff --git a/client/src/components/Assistant/cards/vacay/VacayMonthStrip.tsx b/client/src/components/Assistant/cards/vacay/VacayMonthStrip.tsx
new file mode 100644
index 000000000..01c16a823
--- /dev/null
+++ b/client/src/components/Assistant/cards/vacay/VacayMonthStrip.tsx
@@ -0,0 +1,91 @@
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+
+import { useAssistantView } from '../../assistantViewContext';
+import { useCardStrip } from '../parts/useCardStrip';
+import { type CellSize, monthKey, type PlacedProposal, todayStr, type VacayView } from './vacayCardModel';
+import VacayMiniMonth from './VacayMiniMonth';
+
+/**
+ * The months of a vacation card side by side, to scroll through where they do
+ * not fit; on the phone one month fills the row and the next is a swipe away.
+ * The edges fade where there is more, as in the chat's other strips, and in
+ * the desktop window two arrows over the row page it by whole months.
+ */
+
+const STRIP =
+ 'trek-assistant-strip flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain pb-1 motion-safe:scroll-smooth';
+const WINDOW_MONTH = 'flex-none snap-start';
+const PHONE_MONTH = 'w-full flex-none snap-center';
+const PAGER = 'mb-1 flex justify-end gap-1.5';
+// The look of the arrows over the chat's other strips (parts/CardStrip.tsx).
+const PAGE_ARROW =
+ 'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
+
+/** The two paging arrows, towards the start and towards the end; the months stay reachable by swipe and scroll. */
+const PAGES = [
+ { towards: -1, Icon: ChevronLeft, edge: 'start' },
+ { towards: 1, Icon: ChevronRight, edge: 'end' },
+] as const;
+
+export default function VacayMonthStrip({
+ view,
+ placed,
+ onToggle,
+ labelOf,
+}: {
+ view: VacayView;
+ placed: readonly PlacedProposal[];
+ onToggle?: (toolCallId: string) => void;
+ labelOf: (entry: PlacedProposal) => string;
+}) {
+ const { variant, density } = useAssistantView();
+ // The months as text, so the strip measures again when they change and not on every render.
+ const { listRef, fade, measure, page } = useCardStrip(view.months.map(monthKey).join(','));
+ let size: CellSize = density === 'roomy' ? 'roomy' : 'compact';
+ if (variant === 'fullscreen') size = 'phone';
+ const today = todayStr();
+ const paged = variant === 'window' && (fade.start || fade.end);
+ return (
+ <>
+ {paged && (
+
+ {PAGES.map(({ towards, Icon, edge }) => (
+ // For the pointer only, as over the other strips: the cells are reachable by Tab.
+
+ ))}
+
+ )}
+
+ {view.months.map((month) => (
+
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/client/src/components/Assistant/cards/vacay/VacayRowFacts.tsx b/client/src/components/Assistant/cards/vacay/VacayRowFacts.tsx
new file mode 100644
index 000000000..685372a87
--- /dev/null
+++ b/client/src/components/Assistant/cards/vacay/VacayRowFacts.tsx
@@ -0,0 +1,77 @@
+import type { VacayCard, VacayCardProposal, VacayProposalNote } from '@trek/shared';
+import { CalendarDays, Coffee, Sun, SunDim } from 'lucide-react';
+
+import { useTranslation } from '../../../../i18n';
+import { safeHexColor } from '../../../../utils/safeColor';
+import { type PillTone, SoftPill } from '../../../Planner/planParts';
+import { fmtDays } from '../../../Vacay/vacayFormat';
+import type { ChangeItem } from '../../assistantParts';
+import { FactChange } from '../parts/CardFacts';
+import { useViewerId } from '../parts/useViewerId';
+
+/**
+ * The facts on a vacation row's faint line: how much of the day it is (old
+ * to new when it changes), whose day it is when it is not the user's, what
+ * the user should know about the day, and for a new allowance the allowance
+ * before and after.
+ */
+
+/** What the user should know about a day, in words and a tone. A table, so each key is named where it is read. */
+const NOTE_LOOK: Record = {
+ weekendBlocked: { labelKey: 'assistant.card.vacay.weekendBlocked', tone: 'warning' },
+ weekend: { labelKey: 'assistant.card.vacay.weekend', tone: 'warning' },
+ publicHoliday: { labelKey: 'assistant.card.vacay.publicHoliday', tone: 'info' },
+ companyHoliday: { labelKey: 'assistant.card.vacay.companyHoliday', tone: 'info' },
+ companyHalf: { labelKey: 'assistant.card.vacay.companyHalf', tone: 'info' },
+ notInPlan: { labelKey: 'assistant.card.vacay.notInPlan', tone: 'danger' },
+ companyOff: { labelKey: 'assistant.card.vacay.companyOff', tone: 'warning' },
+};
+
+/** How much of a day an entry is, by its kind and size. */
+const SIZE_LOOK = {
+ comp: { labelKey: 'assistant.card.vacay.compDay', Icon: Coffee },
+ half: { labelKey: 'assistant.card.vacay.halfDay', Icon: SunDim },
+ full: { labelKey: 'assistant.card.vacay.fullDay', Icon: Sun },
+} as const;
+
+function sizeOf(day: Pick): (typeof SIZE_LOOK)[keyof typeof SIZE_LOOK] {
+ if (day.kind === 'comp') return SIZE_LOOK.comp;
+ return day.fraction === 0.5 ? SIZE_LOOK.half : SIZE_LOOK.full;
+}
+
+const DOT = 'size-1.5 flex-none rounded-full';
+
+export default function VacayRowFacts({ card }: { card: VacayCard; item: ChangeItem }) {
+ const { t } = useTranslation();
+ const viewerId = useViewerId();
+ const proposal = card.proposal;
+ if (!proposal) {
+ const balance = card.balances.find((row) => row.totalAfter !== null);
+ if (!balance || balance.totalAfter === null) return null;
+ // The allowance alone, the days carried over taken out of both totals.
+ const carried = balance.carried ?? 0;
+ const before = fmtDays(balance.total - carried);
+ return ;
+ }
+ const shown = proposal.outcome === 'remove' && proposal.before ? proposal.before : proposal;
+ const size = sizeOf(shown);
+ const before = proposal.outcome === 'convert' && proposal.before ? t(sizeOf(proposal.before).labelKey) : null;
+ const person =
+ proposal.personId !== null && proposal.personId !== viewerId
+ ? card.people.find((candidate) => candidate.id === proposal.personId)
+ : undefined;
+ const dotColor = safeHexColor(person?.color, 'var(--accent)');
+ return (
+ <>
+
+ {person && (
+ }>
+ {person.name}
+
+ )}
+ {proposal.notes.map((note) => (
+ {t(NOTE_LOOK[note].labelKey)}
+ ))}
+ >
+ );
+}
diff --git a/client/src/components/Assistant/cards/vacay/vacayCardModel.test.ts b/client/src/components/Assistant/cards/vacay/vacayCardModel.test.ts
new file mode 100644
index 000000000..234a8e6ff
--- /dev/null
+++ b/client/src/components/Assistant/cards/vacay/vacayCardModel.test.ts
@@ -0,0 +1,227 @@
+/**
+ * FE-AI-VACAY-001 to FE-AI-VACAY-007
+ *
+ * The pure side of the vacation cards: several cards read as one calendar,
+ * where each held day stands, the days left before and after the chosen
+ * changes, which days start left out, the cells of a month and their look,
+ * and where the card opens Vacay.
+ */
+import type { VacayCard, VacayCardProposal } from '@trek/shared';
+import { describe, expect, it } from 'vitest';
+
+import type { ChangeItem } from '../../assistantParts';
+import {
+ allowancesOf,
+ balanceLines,
+ lineFigures,
+ mergeVacayCards,
+ monthCells,
+ namesYear,
+ placedProposals,
+ startsExcluded,
+} from './vacayCardModel';
+import { cellLook, entriesFill } from './vacayCellLook';
+import { VACAY_LOOK, vacayPathOf, vacayYearOf } from './vacayLook';
+
+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 BALANCE = {
+ personId: 7,
+ year: 2026,
+ total: 30,
+ used: 28,
+ comp: 0,
+ totalAfter: null,
+ windowStart: '2026-01-01',
+ windowEnd: '2027-01-01',
+};
+
+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: null },
+ ],
+ days: [{ date: '2026-10-26', personId: 7, fraction: 1, kind: 'vacation' }],
+ company: [],
+ holidays: [],
+ tripDates: ['2026-10-28'],
+ proposal: proposal(),
+ balances: [BALANCE],
+ otherDays: 0,
+ ...over,
+});
+
+/** The held days of a cell: each row's state with its day. */
+type HeldStates = Array<[ChangeItem['state'], VacayCardProposal]>;
+
+const item = (id: string, state: ChangeItem['state'], vacay: VacayCard): ChangeItem => ({
+ toolCallId: id,
+ preview: null,
+ applied: null,
+ state,
+ card: vacay,
+});
+
+describe('vacayCardModel', () => {
+ it('FE-AI-VACAY-001: cards of one block read as one calendar, months in order and each day once', () => {
+ const view = mergeVacayCards([
+ card({ months: [{ year: 2026, month: 11 }], otherDays: 2 }),
+ card({ days: [{ date: '2026-10-26', personId: 7, fraction: 0.5, kind: 'vacation' }], otherDays: 1 }),
+ ]);
+ expect(view.months).toEqual([
+ { year: 2026, month: 10 },
+ { year: 2026, month: 11 },
+ ]);
+ expect(view.days).toEqual([{ date: '2026-10-26', personId: 7, fraction: 0.5, kind: 'vacation' }]);
+ expect(view.balances).toHaveLength(1);
+ expect(view.otherDays).toBe(2);
+ expect(view.tripDates.has('2026-10-28')).toBe(true);
+ expect(mergeVacayCards([])).toMatchObject({ months: [], weekStart: 1, weekendDays: [0, 6], otherDays: 0 });
+ });
+
+ it('FE-AI-VACAY-002: a held day stands where its row stands, and the same day held twice is flagged', () => {
+ const excluded = new Set(['c2']);
+ const placed = placedProposals(
+ [
+ item('c1', 'pending', card()),
+ item('c2', 'pending', card()),
+ item('c3', 'discarded', card({ proposal: proposal({ date: '2026-10-29' }) })),
+ item('c4', 'applied', card({ proposal: null })),
+ ],
+ excluded
+ );
+ expect(placed.map((entry) => [entry.toolCallId, entry.state, entry.twice])).toEqual([
+ ['c1', 'pending', true],
+ ['c2', 'excluded', true],
+ ['c3', 'off', false],
+ ]);
+ });
+
+ it('FE-AI-VACAY-003: the days left count the chosen, applying and applied changes, and a new allowance', () => {
+ const halfBack = [{ personId: 7, year: 2026, days: -0.5 }];
+ const giveBack = proposal({ date: '2026-10-30', outcome: 'remove', deltas: halfBack });
+ const items = [
+ item('c1', 'pending', card()),
+ item('c2', 'pending', card({ proposal: proposal({ date: '2026-10-28' }) })),
+ item('c3', 'failed', card({ proposal: proposal({ date: '2026-10-29' }) })),
+ item('c4', 'applied', card({ proposal: giveBack })),
+ ];
+ const view = mergeVacayCards(items.map((entry) => entry.card as VacayCard));
+ const [line] = balanceLines(view, placedProposals(items, new Set()));
+ expect(line).toMatchObject({ personId: 7, year: 2026, used: 28, delta: 1.5, totalAfter: null });
+ expect(line && lineFigures(line)).toEqual({ left: 2, leftAfter: 0.5, over: 0 });
+ const [without] = balanceLines(view, placedProposals(items, new Set(['c1', 'c2'])));
+ expect(without?.delta).toBe(-0.5);
+
+ const allowance = card({
+ proposal: null,
+ months: [],
+ balances: [{ ...BALANCE, totalAfter: 26 }],
+ });
+ const raised = allowancesOf([...items, item('c5', 'pending', allowance)], new Set());
+ expect(raised.get('7:2026')).toBe(26);
+ const [tight] = balanceLines(view, placedProposals(items, new Set()), raised);
+ expect(tight && lineFigures(tight)).toEqual({ left: 2, leftAfter: -3.5, over: 3.5 });
+ expect(allowancesOf([item('c5', 'pending', allowance)], new Set(['c5'])).size).toBe(0);
+
+ // The same day held twice the same way is taken and cleared again: it counts for nothing.
+ const one = mergeVacayCards([card()]);
+ const twins = [item('t1', 'pending', card()), item('t2', 'pending', card())];
+ expect(balanceLines(one, placedProposals(twins, new Set()))[0]?.delta).toBe(0);
+ // Held twice in two sizes, the first says what happens to the day, and it counts once.
+ const halfDay = proposal({ fraction: 0.5, deltas: [{ personId: 7, year: 2026, days: 0.5 }] });
+ const sizes = [item('t1', 'pending', card()), item('t3', 'pending', card({ proposal: halfDay }))];
+ expect(balanceLines(one, placedProposals(sizes, new Set()))[0]?.delta).toBe(1);
+ // One of the pair left out: the other counts in full.
+ expect(balanceLines(one, placedProposals(twins, new Set(['t2'])))[0]?.delta).toBe(1);
+ });
+
+ it('FE-AI-VACAY-004: a refused day, or one with no leave needed, starts left out', () => {
+ expect(startsExcluded(card({ proposal: proposal({ outcome: 'blocked', notes: ['weekendBlocked'] }) }))).toBe(true);
+ expect(startsExcluded(card({ proposal: proposal({ notes: ['publicHoliday'] }) }))).toBe(true);
+ expect(startsExcluded(card({ proposal: proposal({ notes: ['companyHoliday'] }) }))).toBe(true);
+ expect(startsExcluded(card({ proposal: proposal({ notes: ['weekend'] }) }))).toBe(false);
+ expect(startsExcluded(card({ proposal: proposal({ outcome: 'remove', notes: ['publicHoliday'] }) }))).toBe(false);
+ const company = proposal({ personId: null, kind: 'company', notes: ['publicHoliday'] });
+ expect(startsExcluded(card({ proposal: company }))).toBe(false);
+ expect(startsExcluded(card({ proposal: null }))).toBe(false);
+ });
+
+ it('FE-AI-VACAY-005: a month starts on its weekday, marks the weekend, the trip, holidays and the held day', () => {
+ const holidays = [{ date: '2026-10-03', name: 'Unity Day', color: null }];
+ const view = mergeVacayCards([card({ holidays, company: [{ date: '2026-10-30', fraction: 0.5 }] })]);
+ const placed = placedProposals([item('c1', 'pending', card())], new Set());
+ const cells = monthCells(view, { year: 2026, month: 10 }, placed, '2026-10-26');
+ // 1 October 2026 is a Thursday: three empty cells in a week that starts on Monday.
+ expect(cells.slice(0, 3)).toEqual([null, null, null]);
+ expect(cells.filter(Boolean)).toHaveLength(31);
+ const day = (date: string) => cells.find((cell) => cell?.date === date);
+ expect(day('2026-10-03')).toMatchObject({ weekend: true, holiday: { name: 'Unity Day' } });
+ expect(day('2026-10-26')).toMatchObject({ today: true, entries: [{ personId: 7 }] });
+ expect(day('2026-10-27')?.proposals.map((entry) => entry.toolCallId)).toEqual(['c1']);
+ expect(day('2026-10-28')?.trip).toBe(true);
+ expect(day('2026-10-30')?.company).toBe(0.5);
+ const sunday = monthCells({ ...view, weekStart: 0 }, { year: 2026, month: 10 }, [], '2026-10-01');
+ expect(sunday.slice(0, 4)).toEqual([null, null, null, null]);
+ });
+
+ it('FE-AI-VACAY-006: a cell wears the planner colours and the state of its held day', () => {
+ const view = mergeVacayCards([card({ company: [{ date: '2026-10-22', fraction: 1 }] })]);
+ const cellOf = (date: string, held: HeldStates, excluded = new Set()) => {
+ const rows = held.map(([state, day], index) => item(`c${index}`, state, card({ proposal: day })));
+ const cells = monthCells(view, { year: 2026, month: 10 }, placedProposals(rows, excluded), '2026-10-26');
+ const cell = cells.find((entry) => entry?.date === date);
+ if (!cell) throw new Error(`no cell for ${date}`);
+ return cellLook(view, cell);
+ };
+ const take = cellOf('2026-10-27', [['pending', proposal()]]);
+ expect(take.className).toContain('outline-accent');
+ expect(take.style.background).toContain('#6366f1');
+ expect(cellOf('2026-10-27', [['pending', proposal()]], new Set(['c0'])).className).toContain('outline-edge');
+ const giveBack = cellOf('2026-10-26', [['pending', proposal({ date: '2026-10-26', outcome: 'remove' })]]);
+ expect(giveBack.className).toContain('line-through');
+ expect(giveBack.className).toContain('ring-content');
+ expect(cellOf('2026-10-27', [['applying', proposal()]]).className).toContain('animate-pulse');
+ expect(cellOf('2026-10-27', [['failed', proposal()]]).className).toContain('outline-danger');
+ expect(cellOf('2026-10-22', []).className).toContain('bg-warning-soft');
+ expect(cellOf('2026-10-27', [['pending', proposal({ fraction: 0.5 })]]).half).toBe(true);
+ expect(entriesFill(view, [])).toBeNull();
+ expect(
+ entriesFill(view, [
+ { date: '2026-10-26', personId: 7, fraction: 1, kind: 'vacation' },
+ { date: '2026-10-26', personId: 8, fraction: 1, kind: 'comp' },
+ ])
+ ).toContain('linear-gradient(90deg');
+ });
+
+ it('FE-AI-VACAY-007: the card opens Vacay on its leave year, and names the year only where it helps', () => {
+ expect(vacayYearOf(card())).toBe(2026);
+ expect(vacayPathOf(card({ proposal: proposal({ periodYear: 2025 }) }))).toBe('/vacay?year=2025');
+ expect(vacayPathOf(card({ proposal: null, balances: [], months: [] }))).toBe('/vacay');
+ expect(vacayPathOf(null)).toBe('/vacay');
+ // A read of the days off opens the leave year it asked for, not the year of its first month.
+ const asked = card({ proposal: null, balances: [], months: [{ year: 2027, month: 2 }], year: 2026 });
+ expect(vacayPathOf(asked)).toBe('/vacay?year=2026');
+ const applied = { toolCallId: 'c1', entity: 'vacay' as const, ids: [], dayId: null, undoUntil: null };
+ expect(VACAY_LOOK.entities.vacay.open(applied, card())).toEqual({ kind: 'page', path: '/vacay?year=2026' });
+ const line = { personId: 7, year: 2026, total: 30, carried: 0, totalAfter: null, used: 2, comp: 0, delta: 0 };
+ expect(namesYear([line], '2026-10-01')).toBe(false);
+ expect(namesYear([line, { ...line, year: 2027 }], '2026-10-01')).toBe(true);
+ expect(namesYear([line], '2027-01-05')).toBe(true);
+ });
+});
diff --git a/client/src/components/Assistant/cards/vacay/vacayCardModel.ts b/client/src/components/Assistant/cards/vacay/vacayCardModel.ts
new file mode 100644
index 000000000..9a415ae8a
--- /dev/null
+++ b/client/src/components/Assistant/cards/vacay/vacayCardModel.ts
@@ -0,0 +1,284 @@
+import type {
+ VacayCard,
+ VacayCardBalance,
+ VacayCardDay,
+ VacayCardHoliday,
+ VacayCardPerson,
+ VacayCardProposal,
+} from '@trek/shared';
+
+import { localDateStr, monthLead } from '../../../../vacay/monthGrid';
+import type { ChangeItem } from '../../assistantParts';
+
+/**
+ * The pure side of the vacation cards: several cards of one answer or one
+ * change card read as one calendar, where each held day stands and in which
+ * state, the days left before and after the chosen changes, and the cells of
+ * a month. The views only draw what this works out.
+ */
+
+export type CardMonth = VacayCard['months'][number];
+
+/** How large the cells are: the narrow window, the wide one, and the phone, where a cell takes a finger. */
+export type CellSize = 'compact' | 'roomy' | 'phone';
+
+/** Where a held day stands: waiting (taken in or left out), being applied, applied, failed, or off the table. */
+type ProposalState = 'pending' | 'excluded' | 'applying' | 'applied' | 'failed' | 'off';
+
+export interface PlacedProposal {
+ toolCallId: string;
+ proposal: VacayCardProposal;
+ state: ProposalState;
+ /** The same day of the same person is held twice in this card, so the two cancel out. */
+ twice: boolean;
+}
+
+/** Everything the cards of one block show, merged. */
+export interface VacayView {
+ months: CardMonth[];
+ weekStart: 0 | 1;
+ weekendDays: number[];
+ people: VacayCardPerson[];
+ days: VacayCardDay[];
+ company: VacayCard['company'];
+ holidays: VacayCardHoliday[];
+ tripDates: ReadonlySet;
+ balances: VacayCardBalance[];
+ otherDays: number;
+}
+
+/** One person's days left in one leave year, before and after the changes that count. */
+export interface BalanceLine {
+ personId: number;
+ year: number;
+ total: number;
+ /** What was carried over into the year: the allowance alone is `total - carried`. */
+ carried: number;
+ /** The total a new allowance gives, while that change counts. */
+ totalAfter: number | null;
+ used: number;
+ comp: number;
+ /** What the changes that count add to the used days; negative when they give days back. */
+ delta: number;
+}
+
+/** The most months one strip shows. */
+const MAX_MONTHS = 12;
+
+export function monthKey(month: CardMonth): string {
+ return `${month.year}-${String(month.month).padStart(2, '0')}`;
+}
+
+function byKey(rows: readonly T[], key: (row: T) => string): T[] {
+ return [...new Map(rows.map((row) => [key(row), row])).values()];
+}
+
+/** The cards of one block as one calendar: months in order, a day or a holiday once, the last card's word winning. */
+export function mergeVacayCards(cards: readonly VacayCard[]): VacayView {
+ const first = cards[0];
+ const months = byKey(
+ cards.flatMap((card) => card.months),
+ monthKey
+ )
+ .sort((a, b) => monthKey(a).localeCompare(monthKey(b)))
+ .slice(0, MAX_MONTHS);
+ return {
+ months,
+ weekStart: first?.weekStart ?? 1,
+ weekendDays: first?.weekendDays ?? [0, 6],
+ people: byKey(
+ cards.flatMap((card) => card.people),
+ (person) => String(person.id)
+ ),
+ days: byKey(
+ cards.flatMap((card) => card.days),
+ (day) => `${day.date}:${day.personId}`
+ ),
+ company: byKey(
+ cards.flatMap((card) => card.company),
+ (row) => row.date
+ ),
+ holidays: byKey(
+ cards.flatMap((card) => card.holidays),
+ (holiday) => `${holiday.date}:${holiday.name}`
+ ),
+ tripDates: new Set(cards.flatMap((card) => card.tripDates)),
+ balances: byKey(
+ cards.flatMap((card) => card.balances),
+ (balance) => `${balance.personId}:${balance.year}`
+ ),
+ otherDays: Math.max(0, ...cards.map((card) => card.otherDays)),
+ };
+}
+
+/** Where a change stands, with the user's choice of the waiting ones. */
+function proposalState(item: ChangeItem, excluded: ReadonlySet): ProposalState {
+ if (item.state === 'pending') return excluded.has(item.toolCallId) ? 'excluded' : 'pending';
+ if (item.state === 'applying' || item.state === 'applied' || item.state === 'failed') return item.state;
+ return 'off';
+}
+
+/** Whether a change in this state counts for the days left: chosen, on its way, or done. */
+function counts(state: ProposalState): boolean {
+ return state === 'pending' || state === 'applying' || state === 'applied';
+}
+
+/** The vacation cards among the change rows. */
+export function vacayCardsOf(items: readonly ChangeItem[]): Array<{ item: ChangeItem; card: VacayCard }> {
+ return items.flatMap((item) => (item.card?.kind === 'vacay' ? [{ item, card: item.card }] : []));
+}
+
+/** The day a held change is about: one person's day, or the plan's company holiday that day. */
+function dayKeyOf(entry: PlacedProposal): string {
+ return `${entry.proposal.date}:${entry.proposal.personId ?? 'company'}`;
+}
+
+/** What a held change makes of its day: its kind and size. */
+function sizeKeyOf(entry: PlacedProposal): string {
+ return `${entry.proposal.kind}:${entry.proposal.fraction}`;
+}
+
+/** The held days of the rows, each with its state, and which of them name the same day twice. */
+export function placedProposals(items: readonly ChangeItem[], excluded: ReadonlySet): PlacedProposal[] {
+ const placed = vacayCardsOf(items).flatMap(({ item, card }) =>
+ card.proposal
+ ? [{ toolCallId: item.toolCallId, proposal: card.proposal, state: proposalState(item, excluded), twice: false }]
+ : []
+ );
+ const seen = new Map();
+ for (const entry of placed) {
+ if (entry.state !== 'off') seen.set(dayKeyOf(entry), (seen.get(dayKeyOf(entry)) ?? 0) + 1);
+ }
+ return placed.map((entry) => ({ ...entry, twice: entry.state !== 'off' && (seen.get(dayKeyOf(entry)) ?? 0) > 1 }));
+}
+
+/**
+ * The changes to the used days of the held days that count. Each day counts
+ * once: the toggles were all foreseen from the same calendar, so the same day
+ * held twice the same way takes it and clears it again (the pair cancels),
+ * and otherwise the first of them says what happens to the day.
+ */
+function countedDeltas(placed: readonly PlacedProposal[]): VacayCardProposal['deltas'] {
+ const days = new Map();
+ for (const entry of placed.filter((candidate) => counts(candidate.state))) {
+ days.set(dayKeyOf(entry), [...(days.get(dayKeyOf(entry)) ?? []), entry]);
+ }
+ return [...days.values()].flatMap((held) => {
+ const [first] = held;
+ if (!first) return [];
+ const same = held.every((entry) => sizeKeyOf(entry) === sizeKeyOf(first));
+ return same && held.length % 2 === 0 ? [] : first.proposal.deltas;
+ });
+}
+
+/**
+ * The days left per person and year: the balances the cards carry, the held
+ * days that count added to the used days, and a new allowance that counts as
+ * the total it gives.
+ */
+export function balanceLines(
+ view: VacayView,
+ placed: readonly PlacedProposal[],
+ allowances: ReadonlyMap = new Map()
+): BalanceLine[] {
+ const deltas = countedDeltas(placed);
+ return view.balances.map((balance) => {
+ const key = `${balance.personId}:${balance.year}`;
+ const delta = deltas
+ .filter((change) => `${change.personId}:${change.year}` === key)
+ .reduce((sum, change) => sum + change.days, 0);
+ return {
+ personId: balance.personId,
+ year: balance.year,
+ total: balance.total,
+ carried: balance.carried ?? 0,
+ totalAfter: allowances.get(key) ?? null,
+ used: balance.used,
+ comp: balance.comp,
+ delta,
+ };
+ });
+}
+
+/** The totals the new allowances of the rows give, by person and year, while each counts. */
+export function allowancesOf(items: readonly ChangeItem[], excluded: ReadonlySet): Map {
+ const found = new Map();
+ for (const { item, card } of vacayCardsOf(items)) {
+ if (card.proposal || !counts(proposalState(item, excluded))) continue;
+ for (const balance of card.balances) {
+ if (balance.totalAfter !== null) found.set(`${balance.personId}:${balance.year}`, balance.totalAfter);
+ }
+ }
+ return found;
+}
+
+/** What a line comes to: the days left before and after, and how far past the allowance it would go. */
+export function lineFigures(line: BalanceLine): { left: number; leftAfter: number; over: number } {
+ const total = line.totalAfter ?? line.total;
+ const leftAfter = total - (line.used + line.delta);
+ return { left: line.total - line.used, leftAfter, over: Math.max(0, -leftAfter) };
+}
+
+/** Whether the lines need their year named: two years in one card, or a year that is not this one. */
+export function namesYear(lines: readonly BalanceLine[], today: string): boolean {
+ const years = new Set(lines.map((line) => line.year));
+ return years.size > 1 || [...years].some((year) => String(year) !== today.slice(0, 4));
+}
+
+/**
+ * A held day that starts left out: one the planner would refuse, or leave
+ * where no leave is needed (a whole company holiday, a public holiday). The
+ * user takes it in with one tap.
+ */
+export function startsExcluded(card: VacayCard): boolean {
+ const proposal = card.proposal;
+ if (!proposal) return false;
+ if (proposal.outcome === 'blocked') return true;
+ const free = proposal.notes.includes('companyHoliday') || proposal.notes.includes('publicHoliday');
+ return proposal.outcome === 'add' && proposal.personId !== null && free;
+}
+
+export interface DayCell {
+ date: string;
+ day: number;
+ weekend: boolean;
+ today: boolean;
+ entries: VacayCardDay[];
+ /** A company holiday that day: 1 whole, 0.5 half. */
+ company: number | null;
+ holiday: VacayCardHoliday | null;
+ trip: boolean;
+ proposals: PlacedProposal[];
+}
+
+/** The cells of a month, empty ones first for the days before the 1st. */
+export function monthCells(
+ view: VacayView,
+ month: CardMonth,
+ placed: readonly PlacedProposal[],
+ today: string
+): Array {
+ const lead = monthLead(month.year, month.month - 1, view.weekStart);
+ const length = new Date(month.year, month.month, 0).getDate();
+ const cells: Array = Array.from({ length: lead }, () => null);
+ for (let day = 1; day <= length; day++) {
+ const date = localDateStr(month.year, month.month - 1, day);
+ cells.push({
+ date,
+ day,
+ weekend: view.weekendDays.includes(new Date(month.year, month.month - 1, day).getDay()),
+ today: date === today,
+ entries: view.days.filter((entry) => entry.date === date),
+ company: view.company.find((row) => row.date === date)?.fraction ?? null,
+ holiday: view.holidays.find((holiday) => holiday.date === date) ?? null,
+ trip: view.tripDates.has(date),
+ proposals: placed.filter((entry) => entry.proposal.date === date),
+ });
+ }
+ return cells;
+}
+
+/** Today in the user's time zone, as the planner writes a date. */
+export function todayStr(now = new Date()): string {
+ return localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
+}
diff --git a/client/src/components/Assistant/cards/vacay/vacayCellLook.ts b/client/src/components/Assistant/cards/vacay/vacayCellLook.ts
new file mode 100644
index 000000000..427242be1
--- /dev/null
+++ b/client/src/components/Assistant/cards/vacay/vacayCellLook.ts
@@ -0,0 +1,115 @@
+import type { VacayCardDay } from '@trek/shared';
+import type { CSSProperties } from 'react';
+
+import { safeHexColor } from '../../../../utils/safeColor';
+import type { DayCell, PlacedProposal, VacayView } from './vacayCardModel';
+
+/**
+ * How a day of the vacation card looks, worked out apart from the markup:
+ * the planner's own colours (a person's days in their colour, a comp day
+ * hatched, a company holiday amber, a public holiday in its calendar's
+ * colour, the trip's days dotted), and a held day on top of it, dashed while
+ * it waits, solid once applied, struck through when it gives a day back.
+ */
+
+/** The colour a person's days wear, or the company's for a company holiday; the accent when they have none. */
+export function personColor(view: VacayView, personId: number | null): string {
+ if (personId === null) return 'var(--warning)';
+ return safeHexColor(view.people.find((person) => person.id === personId)?.color, 'var(--accent)');
+}
+
+function tint(color: string, percent: number): string {
+ return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
+}
+
+function hatch(color: string): string {
+ return `repeating-linear-gradient(45deg, ${tint(color, 45)} 0 2.5px, transparent 2.5px 5px)`;
+}
+
+/** The logged days of a cell: one person fills it, several share it side by side. */
+export function entriesFill(view: VacayView, entries: readonly VacayCardDay[]): string | null {
+ const shown = entries.slice(0, 3);
+ const [only] = shown;
+ if (!only) return null;
+ if (shown.length === 1) {
+ const color = personColor(view, only.personId);
+ return only.kind === 'comp' ? hatch(color) : tint(color, 26);
+ }
+ const step = 100 / shown.length;
+ const stops = shown.map((entry, index) => {
+ const color = tint(personColor(view, entry.personId), entry.kind === 'comp' ? 18 : 30);
+ return `${color} ${Math.round(index * step)}% ${Math.round((index + 1) * step)}%`;
+ });
+ return `linear-gradient(90deg, ${stops.join(', ')})`;
+}
+
+const BASE = 'relative grid place-items-center rounded-md text-caption tabular-nums leading-none transition-colors';
+const DASHED = 'outline-dashed outline-1 outline-offset-[-1px]';
+
+/** What a held day adds to its cell, by its state and what it does: one text colour, its weight and its outline. */
+type ProposalLook = 'take' | 'giveBack' | 'givenBack' | 'excluded' | 'blocked' | 'applied' | 'failed';
+
+const PROPOSAL_CLASS: Record = {
+ take: `font-semibold text-content ${DASHED} outline-accent`,
+ giveBack: `text-content-faint line-through ${DASHED} outline-danger`,
+ givenBack: 'text-content-faint line-through',
+ excluded: `text-content-faint ${DASHED} outline-edge`,
+ blocked: `text-content-faint ${DASHED} outline-warning`,
+ applied: 'font-semibold text-content',
+ failed: 'text-content outline outline-1 outline-offset-[-1px] outline-danger',
+};
+
+interface CellLook {
+ className: string;
+ style: CSSProperties;
+ /** The held day the cell shows, when one is on the table. */
+ proposal: PlacedProposal | null;
+ /** Someone is off for half of the day. */
+ half: boolean;
+}
+
+function proposalClass(entry: PlacedProposal): string {
+ if (entry.state === 'excluded') return PROPOSAL_CLASS.excluded;
+ if (entry.state === 'failed') return PROPOSAL_CLASS.failed;
+ if (entry.proposal.outcome === 'blocked') return PROPOSAL_CLASS.blocked;
+ const applied = entry.state === 'applied';
+ if (entry.proposal.outcome === 'remove') return applied ? PROPOSAL_CLASS.givenBack : PROPOSAL_CLASS.giveBack;
+ return applied ? PROPOSAL_CLASS.applied : PROPOSAL_CLASS.take;
+}
+
+/** The text of a cell without a held day: a logged day reads strong, a whole company holiday amber, a weekend faint. */
+function plainClass(cell: DayCell): string {
+ if (cell.entries.length > 0) return 'text-content';
+ if (cell.company === 1) return 'bg-warning-soft text-warning';
+ return cell.weekend ? 'text-content-faint' : 'text-content-secondary';
+}
+
+/** The background of a cell: the held day's colour where it takes a day, else what is logged or given. */
+function backgroundOf(view: VacayView, cell: DayCell, entry: PlacedProposal | null): string | undefined {
+ const takes = entry?.proposal.outcome === 'add' || entry?.proposal.outcome === 'convert';
+ if (entry && takes && entry.state !== 'excluded' && entry.state !== 'failed') {
+ return `color-mix(in srgb, ${personColor(view, entry.proposal.personId)} 55%, var(--bg-card))`;
+ }
+ const logged = entriesFill(view, cell.entries);
+ if (logged) return logged;
+ if (cell.company === 0.5) return 'linear-gradient(135deg, var(--warning-soft) 50%, transparent 50%)';
+ if (cell.holiday && cell.company === null) return tint(safeHexColor(cell.holiday.color, 'var(--danger)'), 28);
+ return undefined;
+}
+
+/** The whole look of one cell. */
+export function cellLook(view: VacayView, cell: DayCell): CellLook {
+ const entry = cell.proposals.find((placed) => placed.state !== 'off') ?? null;
+ const classes = [BASE, entry ? proposalClass(entry) : plainClass(cell)];
+ if (entry && cell.company === 1 && cell.entries.length === 0) classes.push('bg-warning-soft');
+ if (cell.today) classes.push('ring-1 ring-inset ring-content');
+ if (cell.today && !entry) classes.push('font-semibold');
+ if (entry?.state === 'applying') classes.push('motion-safe:animate-pulse');
+ const background = backgroundOf(view, cell, entry);
+ return {
+ className: classes.join(' '),
+ style: background ? { background } : {},
+ proposal: entry,
+ half: cell.entries.some((day) => day.fraction === 0.5) || entry?.proposal.fraction === 0.5,
+ };
+}
diff --git a/client/src/components/Assistant/cards/vacay/vacayLook.ts b/client/src/components/Assistant/cards/vacay/vacayLook.ts
index da3d1de6f..59e9d97e6 100644
--- a/client/src/components/Assistant/cards/vacay/vacayLook.ts
+++ b/client/src/components/Assistant/cards/vacay/vacayLook.ts
@@ -1,18 +1,69 @@
+import type { TrekCard } from '@trek/shared';
+import { Building2, CalendarCog, CalendarDays, CalendarMinus, CalendarPlus } from 'lucide-react';
+
import type { PackLook } from '../cardTypes';
/**
* What the vacation planner brings to the chat's pure look: the verbs, tool
* names and steps it adds, what an applied change of its own opens, and the
- * look of each of its kinds. Empty until the vacation cards fill it. A look
- * stays pure, icons and literals only: the chat's main chunk reads every look,
- * so whatever a look imports ships with it.
+ * look of each of its kinds. A look stays pure, icons and literals only: the
+ * chat's main chunk reads every look, so whatever a look imports ships with
+ * it.
*/
+
+/**
+ * The leave year a vacation card is about: the held day's, else the year a
+ * read asked for (a leave year from April shows a February trip of the next
+ * calendar year), else the first of the days left, else its first month's.
+ */
+export function vacayYearOf(card: TrekCard | null): number | null {
+ if (card?.kind !== 'vacay') return null;
+ return card.proposal?.periodYear ?? card.year ?? card.balances[0]?.year ?? card.months[0]?.year ?? null;
+}
+
+/** Vacay on the year of the card, or on the period it would open anyway. */
+export function vacayPathOf(card: TrekCard | null): string {
+ const year = vacayYearOf(card);
+ return year === null ? '/vacay' : `/vacay?year=${year}`;
+}
+
export const VACAY_LOOK = {
- verbs: {},
- tools: {},
- steps: {},
- entities: {},
+ verbs: {
+ addVacationDay: { Icon: CalendarPlus, tone: 'success', labelKey: 'assistant.change.verb.addVacationDay' },
+ removeVacationDay: { Icon: CalendarMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeVacationDay' },
+ changeVacationDay: { Icon: CalendarCog, tone: 'info', labelKey: 'assistant.change.verb.changeVacationDay' },
+ setCompanyHoliday: { Icon: Building2, tone: 'warning', labelKey: 'assistant.change.verb.setCompanyHoliday' },
+ setVacationAllowance: {
+ Icon: CalendarDays,
+ tone: 'info',
+ labelKey: 'assistant.change.verb.setVacationAllowance',
+ },
+ },
+ tools: {
+ add_vacay_year: { labelKey: 'assistant.tool.add_vacay_year' },
+ delete_vacay_year: { labelKey: 'assistant.tool.delete_vacay_year' },
+ update_vacay_plan: { labelKey: 'assistant.tool.update_vacay_plan' },
+ set_vacay_color: { labelKey: 'assistant.tool.set_vacay_color' },
+ add_holiday_calendar: { labelKey: 'assistant.tool.add_holiday_calendar' },
+ delete_holiday_calendar: { labelKey: 'assistant.tool.delete_holiday_calendar' },
+ },
+ steps: {
+ get_vacay_entries: { stepKey: 'assistant.step.vacayEntries' },
+ get_vacay_stats: { stepKey: 'assistant.step.vacayStats' },
+ list_holidays: { stepKey: 'assistant.step.vacayHolidays' },
+ },
+ entities: {
+ vacay: {
+ Icon: CalendarDays,
+ openLabelKey: 'assistant.card.vacay.open',
+ open: (_applied, card) => ({ kind: 'page', path: vacayPathOf(card) }),
+ },
+ },
cards: {
- vacay: {},
+ vacay: {
+ entity: () => 'vacay',
+ // The change card's calendar shows the logged days already, so a read of them next to it gives way.
+ yieldsToChange: (_answer, change) => change.kind === 'vacay',
+ },
},
} satisfies PackLook<'vacay'>;
diff --git a/client/src/components/Assistant/cards/vacay/vacayViews.tsx b/client/src/components/Assistant/cards/vacay/vacayViews.tsx
index 25b445e6c..587e05cfc 100644
--- a/client/src/components/Assistant/cards/vacay/vacayViews.tsx
+++ b/client/src/components/Assistant/cards/vacay/vacayViews.tsx
@@ -1,12 +1,19 @@
-import { type CardView, PENDING_VIEW } from '../cardTypes';
+import type { CardView } from '../cardTypes';
+import VacayAnswerBlock from './VacayAnswerBlock';
+import { startsExcluded } from './vacayCardModel';
+import VacayChangeGroup from './VacayChangeGroup';
+import VacayRowFacts from './VacayRowFacts';
/**
- * How the kinds of the vacation planner draw in the chat; each one draws
- * nothing until its cards are built. This file only maps each kind to its view:
- * the components live in sibling files, since a file that exports anything
- * besides components breaks fast refresh, and lint:warnings holds
- * react-refresh/only-export-components at zero for a new file.
+ * How the vacation planner's card draws in the chat: an answer as a month
+ * calendar with the days left, a change card's vacation rows under one such
+ * calendar, each row's facts on its faint line, and the held days the planner
+ * would refuse or that need no leave left out at first. This file only maps
+ * the kind to its view: the components live in sibling files, since a file
+ * that exports anything besides components breaks fast refresh, and
+ * lint:warnings holds react-refresh/only-export-components at zero for a new
+ * file.
*/
export const VACAY_VIEWS = {
- vacay: PENDING_VIEW,
+ vacay: { Block: VacayAnswerBlock, Group: VacayChangeGroup, Facts: VacayRowFacts, startsExcluded },
} satisfies { [K in 'vacay']: CardView };
diff --git a/client/src/mobile/screens/vacay/MVacayMonth.tsx b/client/src/mobile/screens/vacay/MVacayMonth.tsx
index 51ac21623..4ce215010 100644
--- a/client/src/mobile/screens/vacay/MVacayMonth.tsx
+++ b/client/src/mobile/screens/vacay/MVacayMonth.tsx
@@ -1,5 +1,6 @@
import { schoolHolidayBand } from '../../../components/Vacay/holidayVisual'
-import { dayVisual, hatchTint, localDateStr, monthLead, type DayVisualContext } from './vacayDayModel'
+import { localDateStr, monthLead } from '../../../vacay/monthGrid'
+import { dayVisual, hatchTint, type DayVisualContext } from './vacayDayModel'
interface MVacayMonthProps {
year: number
diff --git a/client/src/mobile/screens/vacay/useMVacay.ts b/client/src/mobile/screens/vacay/useMVacay.ts
index 73532a57a..8f7e5bea7 100644
--- a/client/src/mobile/screens/vacay/useMVacay.ts
+++ b/client/src/mobile/screens/vacay/useMVacay.ts
@@ -5,7 +5,8 @@ import { useVacayStore } from '../../../store/vacayStore'
import { useAuthStore } from '../../../store/authStore'
import { useTranslation } from '../../../i18n'
import { currentPeriodYear, windowMonths } from '../../../vacay/yearWindow'
-import { FALLBACK_PERSON_COLOR, localDateStr, type DayVisualContext } from './vacayDayModel'
+import { localDateStr } from '../../../vacay/monthGrid'
+import { FALLBACK_PERSON_COLOR, type DayVisualContext } from './vacayDayModel'
import { useVacayShareActions } from '../../../components/Vacay/useVacayShareActions'
import {
useDefaultVacayPerson, useVacayCalendarLogic, vacayWindowShape, type VacayMode,
diff --git a/client/src/mobile/screens/vacay/vacayDayModel.ts b/client/src/mobile/screens/vacay/vacayDayModel.ts
index 5c5456861..06e7dfae2 100644
--- a/client/src/mobile/screens/vacay/vacayDayModel.ts
+++ b/client/src/mobile/screens/vacay/vacayDayModel.ts
@@ -168,12 +168,3 @@ export function dayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualCont
}
return visual
}
-
-/** Empty leading cells before the 1st, honoring the configured week start. */
-export function monthLead(year: number, month: number, weekStart: number): number {
- return (new Date(year, month, 1).getDay() - weekStart + 7) % 7
-}
-
-export function localDateStr(year: number, month: number, day: number): string {
- return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
-}
diff --git a/client/src/pages/VacayPage.test.tsx b/client/src/pages/VacayPage.test.tsx
index dac2cb99d..186716909 100644
--- a/client/src/pages/VacayPage.test.tsx
+++ b/client/src/pages/VacayPage.test.tsx
@@ -446,4 +446,15 @@ describe('VacayPage', () => {
vi.useRealTimers();
}
});
+
+ it('FE-PAGE-VACAY-026: a link with a year opens Vacay on it; one without a usable year opens as before', () => {
+ const mockLoadAll = vi.fn().mockResolvedValue(undefined);
+ seedStore(useVacayStore, makeVacayState({ loadAll: mockLoadAll }) as never);
+ const { unmount } = render(, { initialEntries: ['/vacay?year=2027'] });
+ expect(mockLoadAll).toHaveBeenCalledWith(2027);
+ unmount();
+ mockLoadAll.mockClear();
+ render(, { initialEntries: ['/vacay?year=next'] });
+ expect(mockLoadAll).toHaveBeenCalledWith(undefined);
+ });
});
diff --git a/client/src/pages/vacay/useVacay.ts b/client/src/pages/vacay/useVacay.ts
index f248c03da..2c8645b54 100644
--- a/client/src/pages/vacay/useVacay.ts
+++ b/client/src/pages/vacay/useVacay.ts
@@ -1,4 +1,5 @@
import { useEffect, useEffectEvent, useRef, useState } from 'react'
+import { useSearchParams } from 'react-router'
import { useVacayStore } from '../../store/vacayStore'
import { addListener, removeListener } from '../../api/websocket'
import { useToast } from '../../components/shared/Toast'
@@ -13,6 +14,12 @@ const WS_RELOAD_MAX_WAIT_MS = 2000
/** What a vacay event makes the page read again: the plan year, the whole state, the shares. */
type VacayReload = 'plan' | 'all' | 'shares'
+/** The year a link opens Vacay on (`/vacay?year=2026`, from the assistant); none for anything else. */
+function linkedYear(value: string | null): number | undefined {
+ const year = Number(value)
+ return value !== null && Number.isInteger(year) && year >= 2000 && year <= 2100 ? year : undefined
+}
+
const VACAY_RELOADS: Record = {
'vacay:update': ['plan'],
'vacay:settings': ['plan', 'all'],
@@ -40,8 +47,11 @@ export function useVacay() {
const [showMobileSidebar, setShowMobileSidebar] = useState(false)
const toast = useToast()
const { t } = useTranslation()
+ const [searchParams] = useSearchParams()
+ // Read once: the first load opens the linked year when the plan has it, later reloads keep the viewer's.
+ const [preferredYear] = useState(() => linkedYear(searchParams.get('year')))
- useEffect(() => { loadAll() }, [])
+ useEffect(() => { loadAll(preferredYear) }, [])
// Live sync via WebSocket. An agent marking days one by one over MCP sends one
// event per day, so the reads wait until the events stop (or two seconds pass)
diff --git a/client/src/store/vacayStore.test.ts b/client/src/store/vacayStore.test.ts
index 0c6220eab..9d5d26f1c 100644
--- a/client/src/store/vacayStore.test.ts
+++ b/client/src/store/vacayStore.test.ts
@@ -1,4 +1,4 @@
-// FE-STORE-VCY-001 to FE-STORE-VCY-023
+// FE-STORE-VCY-001 to FE-STORE-VCY-024
// Complements tests/unit/stores/vacayStore.test.ts: optimistic toggles and their
// rollbacks, the leave-year window plumbing and the holiday-marker merge rules.
import { describe, it, expect, beforeEach, vi } from 'vitest';
@@ -422,4 +422,17 @@ describe('vacayStore year selection', () => {
expect(useVacayStore.getState().selectedYear).toBe(current);
});
+
+ it('FE-STORE-VCY-024: a year asked for by a link opens when the plan has it, and is ignored when not', async () => {
+ const current = new Date().getFullYear();
+ useVacayStore.setState({ years: [current, current + 1], selectedYear: current });
+ server.use(http.get('/api/addons/vacay/years', () =>
+ HttpResponse.json({ years: [current, current + 1] })));
+
+ await useVacayStore.getState().loadYears(current + 1);
+ expect(useVacayStore.getState().selectedYear).toBe(current + 1);
+
+ await useVacayStore.getState().loadYears(current + 5);
+ expect(useVacayStore.getState().selectedYear).toBe(current + 1);
+ });
});
diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts
index 7ddadfb36..6f3cf6d15 100644
--- a/client/src/store/vacayStore.ts
+++ b/client/src/store/vacayStore.ts
@@ -208,7 +208,8 @@ interface VacayState {
declineInvite: (planId: number) => Promise
cancelInvite: (userId: number) => Promise
dissolve: () => Promise
- loadYears: () => Promise
+ /** Opens `preferred` when the plan has that year (a link from the assistant), else as before. */
+ loadYears: (preferred?: number) => Promise
addYear: (year: number) => Promise
removeYear: (year: number) => Promise
loadEntries: (year?: number) => Promise
@@ -228,7 +229,7 @@ interface VacayState {
setShareHidden: (shareId: number, hidden: boolean) => Promise
loadYearSettings: () => Promise
updateYearSettings: (data: VacayYearSettingsRequest) => Promise
- loadAll: () => Promise
+ loadAll: (preferredYear?: number) => Promise
}
export const useVacayStore = create((set, get) => ({
@@ -305,16 +306,18 @@ export const useVacayStore = create((set, get) => ({
await get().loadAll()
},
- loadYears: async () => {
+ loadYears: async (preferred?: number) => {
const data = await api.getYears()
// Opening Vacay lands on the period we are in, not on whichever year sorts
// last. A reload (invite, live settings update) keeps the year the viewer is
// looking at as long as it still exists — only the first load picks one.
+ // A year asked for by a link wins when the plan has it.
const previous = get().selectedYear
const keepSelection = get().years.length > 0 && data.years.includes(previous)
+ const asked = preferred !== undefined && data.years.includes(preferred) ? preferred : null
set({
years: data.years,
- selectedYear: keepSelection ? previous : defaultPeriodYear(data.years, get().yearSettings),
+ selectedYear: asked ?? (keepSelection ? previous : defaultPeriodYear(data.years, get().yearSettings)),
})
},
@@ -518,14 +521,14 @@ export const useVacayStore = create((set, get) => ({
await get().loadSharedCalendars(year)
},
- loadAll: async () => {
+ loadAll: async (preferredYear?: number) => {
set({ loading: true })
try {
await get().loadPlan()
// The leave-year window decides which period is current, so it is resolved
// before the year list picks a default (#737).
await get().loadYearSettings()
- await get().loadYears()
+ await get().loadYears(preferredYear)
const year = get().selectedYear
await get().loadEntries(year)
await get().loadStats(year)
diff --git a/client/src/vacay/monthGrid.ts b/client/src/vacay/monthGrid.ts
new file mode 100644
index 000000000..ac43ebaae
--- /dev/null
+++ b/client/src/vacay/monthGrid.ts
@@ -0,0 +1,15 @@
+/**
+ * The grid of one month as the vacation planner draws it, shared by the phone's
+ * month view and the assistant's vacation card: how many empty cells come
+ * before the 1st, and a day's date as the planner stores it.
+ */
+
+/** Empty leading cells before the 1st, honoring the configured week start (0 Sunday, 1 Monday). */
+export function monthLead(year: number, month: number, weekStart: number): number {
+ return (new Date(year, month, 1).getDay() - weekStart + 7) % 7;
+}
+
+/** A day as `YYYY-MM-DD`, from a zero-based month. */
+export function localDateStr(year: number, month: number, day: number): string {
+ return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+}
diff --git a/client/tests/unit/mobile/vacay/vacayDayModel.test.ts b/client/tests/unit/mobile/vacay/vacayDayModel.test.ts
index ffa0b1af4..7f216830c 100644
--- a/client/tests/unit/mobile/vacay/vacayDayModel.test.ts
+++ b/client/tests/unit/mobile/vacay/vacayDayModel.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
-import { dayVisual, hatchTint, monthLead, personTint, type DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel';
+import { dayVisual, hatchTint, personTint, type DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel';
+import { localDateStr, monthLead } from '../../../../src/vacay/monthGrid';
// FE-MOB-VACAY-001 onwards
@@ -164,6 +165,7 @@ describe('vacayDayModel', () => {
// July 2026 starts on a Wednesday: 2 leading cells Monday-first, 3 Sunday-first.
expect(monthLead(2026, 6, 1)).toBe(2);
expect(monthLead(2026, 6, 0)).toBe(3);
+ expect(localDateStr(2026, 6, 4)).toBe('2026-07-04');
});
it('FE-MOB-VACAY-003: shared calendars draw inset rings on top of the base cell', () => {
diff --git a/server/src/nest/assistant/cards/vacay/index.ts b/server/src/nest/assistant/cards/vacay/index.ts
index bbcfb04c8..761327d10 100644
--- a/server/src/nest/assistant/cards/vacay/index.ts
+++ b/server/src/nest/assistant/cards/vacay/index.ts
@@ -1,4 +1,58 @@
-import type { DomainCards } from '../card-types';
+import type { DomainCards, ToolCards } from '../card-types';
+import { VACAY_ANSWER_CARDS } from './vacay-answer';
+import { appliedCompany, appliedToggle } from './vacay-applied';
+import { VACAY_CHANGE_CARDS, VACAY_DESCRIBERS } from './vacay-change';
+import { loadDay, loadStats, type VacayLoaded, type VacayStatsLoaded } from './vacay-load';
-/** The cards of the vacation planner. None yet: this package fills them in. */
-export const VACAY_CARDS: DomainCards = { id: 'vacay', tools: {} };
+/**
+ * The vacation planner in the assistant chat. Its reads show the days off,
+ * the days left and the public holidays on a month calendar; a held toggle,
+ * company holiday or new allowance shows the day (or the year) it changes,
+ * with the leave before and after. Vacation days are the plan's, not the
+ * trip's, so each held change looks the plan up first (`load`). A decision
+ * can come hours after the hold, so the applied card follows what the tool
+ * says it did, not what the held card foresaw (`applied`).
+ *
+ * A toggle cannot be undone from the chat yet: whether the day is still as
+ * the change left it needs the change's own date, which an undo check is not
+ * handed (`undo.unchanged` gets the result only, and the result names no
+ * date). The other vacation writes stay cards built from their arguments.
+ */
+
+const toggle: ToolCards = {
+ write: { verbKey: 'addVacationDay', entity: 'vacay' },
+ load: loadDay,
+ describe: VACAY_DESCRIBERS.toggle_vacay_entry,
+ change: VACAY_CHANGE_CARDS.toggle_vacay_entry,
+ applied: appliedToggle,
+};
+
+const companyHoliday: ToolCards = {
+ write: { verbKey: 'setCompanyHoliday', entity: 'vacay' },
+ load: loadDay,
+ describe: VACAY_DESCRIBERS.toggle_company_holiday,
+ change: VACAY_CHANGE_CARDS.toggle_company_holiday,
+ applied: appliedCompany,
+};
+
+const allowance: ToolCards = {
+ write: { verbKey: 'setVacationAllowance', entity: 'vacay' },
+ load: loadStats,
+ describe: VACAY_DESCRIBERS.update_vacay_stats,
+ change: VACAY_CHANGE_CARDS.update_vacay_stats,
+};
+
+export const VACAY_CARDS: DomainCards = {
+ id: 'vacay',
+ tools: {
+ toggle_vacay_entry: toggle,
+ toggle_company_holiday: companyHoliday,
+ update_vacay_stats: allowance,
+ get_vacay_entries: { answer: VACAY_ANSWER_CARDS.get_vacay_entries },
+ get_vacay_stats: { answer: VACAY_ANSWER_CARDS.get_vacay_stats },
+ list_holidays: { answer: VACAY_ANSWER_CARDS.list_holidays },
+ },
+ prompt: [
+ 'toggle_vacay_entry clears a day logged with the same size and kind, so read get_vacay_entries before you toggle.',
+ ],
+};
diff --git a/server/src/nest/assistant/cards/vacay/vacay-answer.ts b/server/src/nest/assistant/cards/vacay/vacay-answer.ts
new file mode 100644
index 000000000..0e97afb5a
--- /dev/null
+++ b/server/src/nest/assistant/cards/vacay/vacay-answer.ts
@@ -0,0 +1,128 @@
+import type { AnswerInput } from '../card-types';
+import {
+ balanceOf,
+ companyIn,
+ daysIn,
+ inMonths,
+ keepKnown,
+ knownPeople,
+ monthKey,
+ monthOf,
+ peopleOf,
+ tripDatesIn,
+ tripMonthsOf,
+ type CardMonth,
+} from './vacay-frame';
+import { yearArg } from './vacay-load';
+import { entriesOf, holidaysOf, statsOf, type VacayEntryRow } from './vacay-reads';
+import { VACAY_CARD_MAX_HOLIDAYS, type VacayCard } from '@trek/shared';
+
+/**
+ * The answer cards of the vacation planner's reads: the days off of a year on
+ * a few months of calendar (`get_vacay_entries`), the days left per person
+ * (`get_vacay_stats`) and a country's public holidays (`list_holidays`). An
+ * answer card has the read's own answer and the trip card, nothing else, so
+ * it draws the planner's usual week (Monday first, Saturday and Sunday off).
+ */
+
+const MAX_MONTHS = 3;
+
+/** An answer card with nothing on it yet. */
+function emptyCard(): VacayCard {
+ return {
+ kind: 'vacay',
+ months: [],
+ weekStart: 1,
+ weekendDays: [0, 6],
+ people: [],
+ days: [],
+ company: [],
+ holidays: [],
+ tripDates: [],
+ proposal: null,
+ balances: [],
+ otherDays: 0,
+ };
+}
+
+/** The distinct months of `dates` in order, at most `max`. */
+function monthsOfDates(dates: readonly string[], max: number): CardMonth[] {
+ const keys = [...new Set([...dates].sort().map(monthKey))].slice(0, max);
+ return keys.map((key) => monthOf(`${key}-01`));
+}
+
+/**
+ * The months a year's days off are shown on: the trip's months inside the
+ * year, else the coming months the signed-in user is off in, else this month
+ * when it lies in the year, else the year's first month. The year is taken
+ * from January to December, widened to the months the answer holds days of.
+ */
+function focusMonths(input: AnswerInput, entries: readonly VacayEntryRow[], year: number): CardMonth[] {
+ const span = [`${year}-01`, `${year}-12`, ...entries.map((entry) => monthKey(entry.date))].sort();
+ const first = span[0] ?? `${year}-01`;
+ const last = span[span.length - 1] ?? `${year}-12`;
+ const inside = (key: string) => key >= first && key <= last;
+ const trip = tripMonthsOf(input.card, 12).filter((month) => inside(monthKey(month)));
+ if (trip.length > 0) return trip.slice(0, MAX_MONTHS);
+ const coming = entries
+ .filter((entry) => entry.personId === input.card.viewerId && entry.date >= input.today)
+ .map((entry) => entry.date);
+ if (coming.length > 0) return monthsOfDates(coming, MAX_MONTHS);
+ if (inside(monthKey(input.today))) return [monthOf(input.today)];
+ return [monthOf(`${first}-01`)];
+}
+
+export const VACAY_ANSWER_CARDS = {
+ get_vacay_entries: (input: AnswerInput): VacayCard | null => {
+ const logged = entriesOf(input.value);
+ if (!logged) return null;
+ const viewerId = input.card.viewerId;
+ const year = yearArg(input.args);
+ const months = focusMonths(input, logged.entries, year ?? Number(input.today.slice(0, 4)));
+ const days = daysIn(logged.entries, months, viewerId);
+ const people = peopleOf(
+ [viewerId, ...days.map((day) => day.personId)],
+ knownPeople(null, logged.entries, []),
+ viewerId,
+ );
+ const otherDays = logged.entries
+ .filter((entry) => entry.personId === viewerId && entry.kind === 'vacation' && !inMonths(entry.date, months))
+ .reduce((sum, entry) => sum + entry.fraction, 0);
+ return {
+ ...emptyCard(),
+ months,
+ people,
+ days: keepKnown(days, people),
+ company: companyIn(logged.company, months),
+ tripDates: tripDatesIn(input.card, months),
+ otherDays: Math.min(1000, otherDays),
+ // The leave year asked for, which Vacay opens on: a leave year from April holds a February trip of the next.
+ ...(year === null ? {} : { year }),
+ };
+ },
+ get_vacay_stats: (input: AnswerInput): VacayCard | null => {
+ const stats = statsOf(input.value);
+ if (!stats || stats.length === 0) return null;
+ const viewerId = input.card.viewerId;
+ const people = peopleOf(stats.map((row) => row.personId), knownPeople(null, [], stats), viewerId);
+ const order = new Map(people.map((person, index) => [person.id, index]));
+ const balances = keepKnown(stats, people)
+ .sort((a, b) => (order.get(a.personId) ?? 0) - (order.get(b.personId) ?? 0))
+ .map((row) => balanceOf(row));
+ return { ...emptyCard(), people, balances };
+ },
+ list_holidays: (input: AnswerInput): VacayCard | null => {
+ const holidays = holidaysOf(input.value, null, null);
+ if (holidays.length === 0) return null;
+ const dates = holidays.map((holiday) => holiday.date);
+ const trip = tripMonthsOf(input.card).filter((month) => dates.some((date) => inMonths(date, [month])));
+ const coming = dates.filter((date) => date >= input.today);
+ const months = trip.length > 0 ? trip : monthsOfDates(coming.length > 0 ? coming : dates, 2);
+ return {
+ ...emptyCard(),
+ months,
+ holidays: holidays.filter((holiday) => inMonths(holiday.date, months)).slice(0, VACAY_CARD_MAX_HOLIDAYS),
+ tripDates: tripDatesIn(input.card, months),
+ };
+ },
+};
diff --git a/server/src/nest/assistant/cards/vacay/vacay-applied.ts b/server/src/nest/assistant/cards/vacay/vacay-applied.ts
new file mode 100644
index 000000000..d944a759c
--- /dev/null
+++ b/server/src/nest/assistant/cards/vacay/vacay-applied.ts
@@ -0,0 +1,103 @@
+import type { ToolArgs } from '../card-types';
+import { recordOf } from '../card-values';
+import { counted } from './vacay-change';
+import type { Fraction } from './vacay-reads';
+import type { AssistantChangePreview, VacayCard, VacayCardProposal } from '@trek/shared';
+
+/**
+ * The card of an applied vacation change. The change card was drawn when the
+ * call was held, which can be hours before the user decided, and the day may
+ * have changed since; the tool's answer says what was really done (`added`,
+ * `removed`, `updated`). Where that is what the card foresaw, the card stands.
+ * Where it is not, the day shows what was done, and the days left follow the
+ * answer where it tells enough to count them and change nothing where it does
+ * not, rather than show a count the day never got.
+ */
+
+type Outcome = VacayCardProposal['outcome'];
+type DayKind = VacayCardProposal['kind'];
+
+interface AppliedInput {
+ args: ToolArgs;
+ result: unknown;
+ preview: AssistantChangePreview | null;
+}
+
+const DONE = new Map([
+ ['added', 'add'],
+ ['removed', 'remove'],
+ ['updated', 'convert'],
+]);
+
+/** The held card with its day, when the stored preview has one. */
+function heldOf(preview: AssistantChangePreview | null): { card: VacayCard; proposal: VacayCardProposal } | null {
+ const card = preview?.card;
+ if (card?.kind !== 'vacay' || !card.proposal) return null;
+ return { card, proposal: card.proposal };
+}
+
+/** What the tool says it did; null for an answer that names nothing (a refusal). */
+function doneOf(result: unknown): Outcome | null {
+ const action = recordOf(result).action;
+ return typeof action === 'string' ? (DONE.get(action) ?? null) : null;
+}
+
+function fractionOf(value: unknown, fallback: Fraction): Fraction {
+ if (value === 0.5 || value === 1) return value;
+ return fallback;
+}
+
+function kindOf(value: unknown, fallback: DayKind): DayKind {
+ if (value === 'vacation' || value === 'comp') return value;
+ return fallback;
+}
+
+/**
+ * What a toggle that did `outcome` adds to the used days: an added day by its
+ * own size, a cleared one by the size it was asked with (the planner clears
+ * only that), a converted one only when the card knew the day before.
+ */
+function toggleDays(
+ outcome: Outcome,
+ proposal: VacayCardProposal,
+ done: { fraction: Fraction; kind: DayKind },
+): number | null {
+ if (outcome === 'add') return counted(done.kind, done.fraction);
+ if (outcome === 'remove') return -counted(proposal.kind, proposal.fraction);
+ if (outcome === 'convert' && proposal.before) {
+ return counted(done.kind, done.fraction) - counted(proposal.before.kind, proposal.before.fraction);
+ }
+ return null;
+}
+
+/** A day off as it was applied. */
+export function appliedToggle({ result, preview }: AppliedInput): VacayCard | null {
+ const held = heldOf(preview);
+ const outcome = doneOf(result);
+ if (!held || !outcome) return null;
+ const { card, proposal } = held;
+ const answer = recordOf(result);
+ const fraction = fractionOf(answer.fraction, proposal.fraction);
+ const kind = kindOf(answer.kind, proposal.kind);
+ if (outcome === proposal.outcome && fraction === proposal.fraction && kind === proposal.kind) return card;
+ const days = toggleDays(outcome, proposal, { fraction, kind });
+ const { personId, periodYear } = proposal;
+ const deltas =
+ days === null || personId === null || periodYear === null ? [] : [{ personId, year: periodYear, days }];
+ return { ...card, proposal: { ...proposal, outcome, fraction, kind, deltas } };
+}
+
+/**
+ * A company holiday as it was applied. What it gave back depends on the days
+ * logged when it was applied, which the answer does not name, so a holiday
+ * that did something else than foreseen changes nobody's days left.
+ */
+export function appliedCompany({ result, preview }: AppliedInput): VacayCard | null {
+ const held = heldOf(preview);
+ const outcome = doneOf(result);
+ if (!held || !outcome) return null;
+ const { card, proposal } = held;
+ const fraction = fractionOf(recordOf(result).fraction, proposal.fraction);
+ if (outcome === proposal.outcome && fraction === proposal.fraction) return card;
+ return { ...card, proposal: { ...proposal, outcome, fraction, deltas: [] } };
+}
diff --git a/server/src/nest/assistant/cards/vacay/vacay-change.ts b/server/src/nest/assistant/cards/vacay/vacay-change.ts
new file mode 100644
index 000000000..ef587bc83
--- /dev/null
+++ b/server/src/nest/assistant/cards/vacay/vacay-change.ts
@@ -0,0 +1,250 @@
+import type { Described, PreviewContext, ToolArgs } from '../card-types';
+import { argId } from '../card-values';
+import { formatDate, Rows, text } from '../preview-format';
+import {
+ balanceOf,
+ companyIn,
+ daysIn,
+ inMonths,
+ keepKnown,
+ knownPeople,
+ monthOf,
+ peopleOf,
+ periodOf,
+ tripDatesIn,
+} from './vacay-frame';
+import { dateArg, yearArg, type VacayLoaded, type VacayStatsLoaded } from './vacay-load';
+import type { Fraction, VacayEntryRow } from './vacay-reads';
+import {
+ VACAY_CARD_MAX_HOLIDAYS,
+ type AssistantChangeVerbKey,
+ type VacayCard,
+ type VacayCardProposal,
+ type VacayDayKind,
+ type VacayProposalNote,
+} from '@trek/shared';
+
+/**
+ * The change cards of the vacation planner: a day off taken, given back or
+ * changed (`toggle_vacay_entry`), a company holiday (`toggle_company_holiday`)
+ * and a new allowance (`update_vacay_stats`). A toggle's arguments do not say
+ * whether it adds or clears a day, so the card works it out from what is
+ * logged, the way the planner's service will: the same size and kind again
+ * clears the day, another converts it, a blocked weekend refuses it, and half
+ * a company holiday leaves room for half a day only. The card shows the day on
+ * its month with the leave before it; the client works out the leave after.
+ */
+
+/** A day off as it counts against the allowance: leave by its size, a comp day not at all. */
+export function counted(kind: VacayDayKind, fraction: Fraction): number {
+ return kind === 'vacation' ? fraction : 0;
+}
+
+function weekdayOf(date: string): number {
+ return new Date(`${date}T00:00:00Z`).getUTCDay();
+}
+
+function fractionArg(value: unknown): Fraction {
+ return value === 0.5 ? 0.5 : 1;
+}
+
+/** What a toggle does to a day: the same size and kind clears it, another converts it, a blocked weekend refuses. */
+function toggleOutcome(
+ inPlan: boolean,
+ existing: VacayEntryRow | undefined,
+ asked: { fraction: Fraction; kind: 'vacation' | 'comp' },
+ blockedDay: boolean,
+): VacayCardProposal['outcome'] {
+ if (!inPlan) return 'blocked';
+ if (existing && existing.fraction === asked.fraction && existing.kind === asked.kind) return 'remove';
+ if (blockedDay) return 'blocked';
+ return existing ? 'convert' : 'add';
+}
+
+/** What the user should know about the day before the toggle is applied. */
+function toggleNotes(
+ loaded: VacayLoaded,
+ date: string,
+ facts: { inPlan: boolean; weekend: boolean; counts: boolean; outcome: VacayCardProposal['outcome'] },
+): VacayProposalNote[] {
+ const { plan } = loaded;
+ const company = plan.companyOn ? loaded.company.find((row) => row.date === date) : undefined;
+ const notes: VacayProposalNote[] = [];
+ if (!facts.inPlan) notes.push('notInPlan');
+ else if (facts.weekend && plan.blockWeekends && facts.outcome === 'blocked') notes.push('weekendBlocked');
+ if (facts.weekend && !plan.blockWeekends && facts.counts) notes.push('weekend');
+ if (loaded.holidays.some((holiday) => holiday.date === date)) notes.push('publicHoliday');
+ if (company?.fraction === 1) notes.push('companyHoliday');
+ if (company?.fraction === 0.5) notes.push('companyHalf');
+ return notes;
+}
+
+/** What a toggle of `date` would do, for whom and to which leave year. */
+function toggleProposal(viewerId: number, args: ToolArgs, loaded: VacayLoaded, date: string): VacayCardProposal {
+ const { plan } = loaded;
+ const named = argId(args, 'targetUserId');
+ // A target that is no id is refused by the tool's schema: the card says so instead of showing the user's own day.
+ const unreadable = named === null && args.targetUserId !== undefined && args.targetUserId !== null;
+ const personId = named ?? viewerId;
+ const inPlan = !unreadable && plan.people.has(personId);
+ const company = plan.companyOn ? loaded.company.find((row) => row.date === date) : undefined;
+ const fraction: Fraction = company?.fraction === 0.5 ? 0.5 : fractionArg(args.fraction);
+ const kind = args.kind === 'comp' ? 'comp' : 'vacation';
+ const existing = loaded.entries.find((entry) => entry.date === date && entry.personId === personId);
+ const weekend = plan.weekendDays.includes(weekdayOf(date));
+ const outcome = toggleOutcome(inPlan, existing, { fraction, kind }, weekend && plan.blockWeekends);
+ const was = existing ? counted(existing.kind, existing.fraction) : 0;
+ const will = outcome === 'remove' ? 0 : outcome === 'blocked' ? was : counted(kind, fraction);
+ const year = Number(date.slice(0, 4));
+ const periodYear = periodOf(date, loaded.stats.find((row) => row.personId === personId && row.year === year));
+ const counts = kind === 'vacation' && (outcome === 'add' || outcome === 'convert');
+ return {
+ date,
+ personId,
+ kind,
+ fraction,
+ outcome,
+ before: existing ? { fraction: existing.fraction, kind: existing.kind } : null,
+ periodYear,
+ notes: toggleNotes(loaded, date, { inPlan, weekend, counts, outcome }),
+ deltas: inPlan ? [{ personId, year: periodYear, days: will - was }] : [],
+ };
+}
+
+/** What a whole company holiday takes off a logged day, and a half one: a whole day becomes half a day. */
+function companyDelta(entry: VacayEntryRow, fraction: Fraction): number {
+ if (fraction === 1) return -counted(entry.kind, entry.fraction);
+ return entry.fraction === 1 ? -counted(entry.kind, 0.5) : 0;
+}
+
+/** What a company holiday on `date` would do to the plan and to everyone's leave that day. */
+function companyProposal(args: ToolArgs, loaded: VacayLoaded, date: string): VacayCardProposal {
+ const fraction: Fraction = args.half === true ? 0.5 : 1;
+ const existing = loaded.company.find((row) => row.date === date);
+ let outcome: VacayCardProposal['outcome'] = existing ? 'convert' : 'add';
+ if (existing?.fraction === fraction) outcome = 'remove';
+ const year = Number(date.slice(0, 4));
+ const deltas =
+ outcome === 'remove'
+ ? []
+ : loaded.entries.flatMap((entry) => {
+ const days = entry.date === date ? companyDelta(entry, fraction) : 0;
+ if (days === 0) return [];
+ const row = loaded.stats.find((stat) => stat.personId === entry.personId && stat.year === year);
+ return [{ personId: entry.personId, year: periodOf(date, row), days }];
+ });
+ const notes: VacayProposalNote[] = [];
+ // The planner refuses a company holiday while they are off, the tool still writes one: the card warns.
+ if (!loaded.plan.companyOn) notes.push('companyOff');
+ if (loaded.holidays.some((holiday) => holiday.date === date)) notes.push('publicHoliday');
+ return {
+ date,
+ personId: null,
+ kind: 'company',
+ fraction,
+ outcome,
+ before: existing ? { fraction: existing.fraction, kind: 'company' } : null,
+ periodYear: null,
+ notes,
+ deltas: deltas.slice(0, 8),
+ };
+}
+
+/** The month of a held day with what is logged there, the change and the leave before it. */
+function dayCard(ctx: PreviewContext, loaded: VacayLoaded, proposal: VacayCardProposal): VacayCard {
+ const viewerId = ctx.card.viewerId;
+ const months = [monthOf(proposal.date)];
+ const days = daysIn(loaded.entries, months, viewerId);
+ const touched = proposal.personId === null ? proposal.deltas.map((delta) => delta.personId) : [proposal.personId];
+ const known = knownPeople(loaded.plan.people, loaded.entries, loaded.stats);
+ const people = peopleOf([viewerId, ...touched, ...days.map((day) => day.personId)], known, viewerId);
+ const periods = new Set(proposal.deltas.map((delta) => `${delta.personId}:${delta.year}`));
+ const rows = loaded.stats.filter((row) => periods.has(`${row.personId}:${row.year}`));
+ const balances = rows.map((row) => balanceOf(row));
+ return {
+ kind: 'vacay',
+ months,
+ weekStart: loaded.plan.weekStart,
+ weekendDays: loaded.plan.weekendDays,
+ people,
+ days: keepKnown(days, people),
+ company: companyIn(loaded.company, months),
+ holidays: loaded.holidays.filter((row) => inMonths(row.date, months)).slice(0, VACAY_CARD_MAX_HOLIDAYS),
+ tripDates: tripDatesIn(ctx.card, months),
+ proposal,
+ balances: keepKnown(balances, people),
+ otherDays: 0,
+ };
+}
+
+/** The verb a toggle's card is labelled with: what it would do to the day. */
+const TOGGLE_VERBS = {
+ add: 'addVacationDay',
+ remove: 'removeVacationDay',
+ convert: 'changeVacationDay',
+ blocked: 'addVacationDay',
+} as const satisfies Record;
+
+/** A held day's words: its date, in the vacation planner; no rows, the card states the facts. */
+function dayDescribed(ctx: PreviewContext, args: ToolArgs): Described {
+ const date = dateArg(args);
+ return {
+ subject: date ? formatDate(ctx, date) : text(args.date),
+ rows: new Rows(),
+ dayId: null,
+ placeIds: [],
+ domain: 'vacay',
+ };
+}
+
+export const VACAY_DESCRIBERS = {
+ toggle_vacay_entry: (ctx: PreviewContext, args: ToolArgs, loaded: VacayLoaded | null): Described => {
+ const described = dayDescribed(ctx, args);
+ const date = dateArg(args);
+ if (!loaded || !date) return described;
+ return { ...described, verbKey: TOGGLE_VERBS[toggleProposal(ctx.card.viewerId, args, loaded, date).outcome] };
+ },
+ toggle_company_holiday: (ctx: PreviewContext, args: ToolArgs): Described => {
+ const described = dayDescribed(ctx, args);
+ described.rows.add('notes', text(args.note));
+ return described;
+ },
+ update_vacay_stats: (_ctx: PreviewContext, args: ToolArgs): Described => {
+ const year = yearArg(args);
+ const rows = new Rows().add('quantity', text(args.vacationDays));
+ return { subject: year === null ? '' : String(year), rows, dayId: null, placeIds: [], domain: 'vacay' };
+ },
+};
+
+export const VACAY_CHANGE_CARDS = {
+ toggle_vacay_entry: (ctx: PreviewContext, args: ToolArgs, loaded: VacayLoaded | null): VacayCard | null => {
+ const date = dateArg(args);
+ return loaded && date ? dayCard(ctx, loaded, toggleProposal(ctx.card.viewerId, args, loaded, date)) : null;
+ },
+ toggle_company_holiday: (ctx: PreviewContext, args: ToolArgs, loaded: VacayLoaded | null): VacayCard | null => {
+ const date = dateArg(args);
+ return loaded && date ? dayCard(ctx, loaded, companyProposal(args, loaded, date)) : null;
+ },
+ /** The signed-in user's leave in that year, with the total the new allowance gives. */
+ update_vacay_stats: (ctx: PreviewContext, args: ToolArgs, loaded: VacayStatsLoaded | null): VacayCard | null => {
+ const year = yearArg(args);
+ const allowance = args.vacationDays;
+ const row = loaded?.stats.find((stat) => stat.personId === ctx.card.viewerId && stat.year === year);
+ if (!row || typeof allowance !== 'number' || !Number.isInteger(allowance) || allowance < 0) return null;
+ const known = knownPeople(null, [], [row]);
+ return {
+ kind: 'vacay',
+ months: [],
+ weekStart: 1,
+ weekendDays: [0, 6],
+ people: peopleOf([row.personId], known, ctx.card.viewerId),
+ days: [],
+ company: [],
+ holidays: [],
+ tripDates: [],
+ proposal: null,
+ balances: [balanceOf(row, Math.min(1000, allowance + row.carried))],
+ otherDays: 0,
+ };
+ },
+};
diff --git a/server/src/nest/assistant/cards/vacay/vacay-frame.ts b/server/src/nest/assistant/cards/vacay/vacay-frame.ts
new file mode 100644
index 000000000..f7b0019b0
--- /dev/null
+++ b/server/src/nest/assistant/cards/vacay/vacay-frame.ts
@@ -0,0 +1,150 @@
+import type { TripCard } from '../../assistant-context';
+import type { VacayEntryRow, VacayPerson, VacayStatRow } from './vacay-reads';
+import {
+ VACAY_CARD_MAX_DATES,
+ VACAY_CARD_MAX_DAYS,
+ type VacayCard,
+ type VacayCardBalance,
+ type VacayCardDay,
+ type VacayCardPerson,
+} from '@trek/shared';
+
+/**
+ * The calendar part every vacation card shares: which months it shows, the
+ * days of the trip and the logged days inside them, and the people those
+ * days belong to, the signed-in user first.
+ */
+
+export type CardMonth = VacayCard['months'][number];
+
+const MAX_PEOPLE = 8;
+
+/** "2026-10" for a date or a month. */
+export function monthKey(value: string | CardMonth): string {
+ return typeof value === 'string' ? value.slice(0, 7) : `${value.year}-${String(value.month).padStart(2, '0')}`;
+}
+
+export function monthOf(date: string): CardMonth {
+ return { year: Number(date.slice(0, 4)), month: Number(date.slice(5, 7)) };
+}
+
+export function inMonths(date: string, months: readonly CardMonth[]): boolean {
+ const key = monthKey(date);
+ return months.some((month) => monthKey(month) === key);
+}
+
+/** The dated days of the trip, from its days or else its first and last date (a year of them at most). */
+function tripDatesOf(card: TripCard): string[] {
+ const dated = card.days.flatMap((day) => (day.date ? [day.date] : []));
+ if (dated.length > 0) return [...new Set(dated)].sort();
+ if (!card.startDate || !card.endDate || card.startDate > card.endDate) return [];
+ const dates: string[] = [];
+ const day = new Date(`${card.startDate}T00:00:00Z`);
+ while (dates.length < 366) {
+ const iso = day.toISOString().slice(0, 10);
+ if (iso > card.endDate) break;
+ dates.push(iso);
+ day.setUTCDate(day.getUTCDate() + 1);
+ }
+ return dates;
+}
+
+/** The dated days of the trip inside `months`, as many as a card holds. */
+export function tripDatesIn(card: TripCard, months: readonly CardMonth[]): string[] {
+ return tripDatesOf(card).filter((date) => inMonths(date, months)).slice(0, VACAY_CARD_MAX_DATES);
+}
+
+/** The company holidays inside `months`, each date once. */
+export function companyIn(rows: readonly T[], months: readonly CardMonth[]): T[] {
+ const inside = rows.filter((row) => inMonths(row.date, months));
+ return [...new Map(inside.map((row) => [row.date, row])).values()].slice(0, VACAY_CARD_MAX_DATES);
+}
+
+/** The trip's months in order, at most `max`. */
+export function tripMonthsOf(card: TripCard, max = 3): CardMonth[] {
+ const keys = [...new Set(tripDatesOf(card).map(monthKey))].slice(0, max);
+ return keys.map((key) => monthOf(`${key}-01`));
+}
+
+/** A logged day as a card holds it. */
+function cardDay(entry: VacayEntryRow): VacayCardDay {
+ return { date: entry.date, personId: entry.personId, fraction: entry.fraction, kind: entry.kind };
+}
+
+/** The logged days inside `months`, the signed-in user's first when there are more than a card holds. */
+export function daysIn(
+ entries: readonly VacayEntryRow[],
+ months: readonly CardMonth[],
+ viewerId: number,
+): VacayCardDay[] {
+ const inside = entries.filter((entry) => inMonths(entry.date, months));
+ const ranked = [...inside].sort(
+ (a, b) => Number(b.personId === viewerId) - Number(a.personId === viewerId) || a.date.localeCompare(b.date),
+ );
+ return ranked
+ .slice(0, VACAY_CARD_MAX_DAYS)
+ .map(cardDay)
+ .sort((a, b) => a.date.localeCompare(b.date) || a.personId - b.personId);
+}
+
+/**
+ * The people a card names, by id: the signed-in user first, then in the
+ * order they come up. A person no source names is left out, and `keepKnown`
+ * leaves out their days and balances too.
+ */
+export function peopleOf(
+ ids: readonly number[],
+ known: ReadonlyMap,
+ viewerId: number,
+): VacayCardPerson[] {
+ const order = [...new Set(ids)].sort((a, b) => Number(b === viewerId) - Number(a === viewerId));
+ return order
+ .flatMap((id) => {
+ const person = known.get(id);
+ return person ? [{ id, name: person.name, avatarKey: null, color: person.color }] : [];
+ })
+ .slice(0, MAX_PEOPLE);
+}
+
+/** Only the rows of the people a card names. */
+export function keepKnown(rows: readonly T[], people: readonly VacayCardPerson[]): T[] {
+ const ids = new Set(people.map((person) => person.id));
+ return rows.filter((row) => ids.has(row.personId));
+}
+
+/** The people every source names, the plan's own names first. */
+export function knownPeople(
+ plan: ReadonlyMap | null,
+ entries: readonly VacayEntryRow[],
+ stats: readonly VacayStatRow[],
+): Map {
+ const known = new Map();
+ for (const row of [...entries, ...stats]) {
+ if (row.person && !known.has(row.personId)) known.set(row.personId, row.person);
+ }
+ for (const [id, person] of plan ?? []) known.set(id, person);
+ return known;
+}
+
+/** A person's leave in a year as a card shows it. */
+export function balanceOf(row: VacayStatRow, totalAfter: number | null = null): VacayCardBalance {
+ return {
+ personId: row.personId,
+ year: row.year,
+ total: row.total,
+ carried: Math.min(row.carried, row.total),
+ used: row.used,
+ comp: row.comp,
+ totalAfter,
+ windowStart: row.windowStart,
+ windowEnd: row.windowEnd,
+ };
+}
+
+/** The leave year a date counts in for a person, from their row of the date's calendar year. */
+export function periodOf(date: string, row: VacayStatRow | undefined): number {
+ const year = Number(date.slice(0, 4));
+ if (!row) return year;
+ if (date < row.windowStart) return row.year - 1;
+ return date >= row.windowEnd ? row.year + 1 : row.year;
+}
diff --git a/server/src/nest/assistant/cards/vacay/vacay-load.ts b/server/src/nest/assistant/cards/vacay/vacay-load.ts
new file mode 100644
index 000000000..89e9319e1
--- /dev/null
+++ b/server/src/nest/assistant/cards/vacay/vacay-load.ts
@@ -0,0 +1,116 @@
+import type { CardReader, ToolArgs } from '../card-types';
+import { argId } from '../card-values';
+import {
+ countryOf,
+ entriesOf,
+ holidaysOf,
+ isoDate,
+ planOf,
+ statsOf,
+ type VacayCompanyRow,
+ type VacayEntryRow,
+ type VacayHolidayRow,
+ type VacayPlanView,
+ type VacayStatRow,
+} from './vacay-reads';
+
+/**
+ * What a held vacation write is described with, read before its card is
+ * drawn: the plan (people, colours, weekends, calendars), the days left of
+ * the leave year the day falls in, that year's logged days and company
+ * holidays, and the public holidays of the plan's first calendar. Vacation
+ * days belong to the plan, not to the trip, so nothing of this is on the trip
+ * card.
+ *
+ * A lookup may read four times. The plan and the days left of the day's
+ * calendar year come first; a leave year that starts later in the year (a
+ * fiscal or a hire-date year) can put the day in the year before, which takes
+ * the days left of that year too and leaves no read for the holidays, unless
+ * the toggle names a person whose own year holds the day. Who the signed-in
+ * user is, the card decides later from the trip card.
+ *
+ * The logged days come from the signed-in user's own calendar grid, which
+ * runs from the first of the month their leave year starts in. The lookup
+ * cannot tell which person that is, so it reads the grid year every person
+ * agrees on; where their years start in different months and split on the
+ * day, the existing entry could be missed, and the lookup gives nothing (the
+ * generic card) rather than a card that says "add" where the toggle removes.
+ */
+
+export interface VacayLoaded {
+ plan: VacayPlanView;
+ /** The days left per person, of the day's calendar year and, where it counts there, the year before. */
+ stats: VacayStatRow[];
+ entries: VacayEntryRow[];
+ company: VacayCompanyRow[];
+ holidays: VacayHolidayRow[];
+}
+
+export interface VacayStatsLoaded {
+ stats: VacayStatRow[];
+}
+
+/** The day a held vacation write names, when it names a real one. */
+export function dateArg(args: ToolArgs): string | null {
+ const date = isoDate(args.date);
+ return date && !Number.isNaN(Date.parse(`${date}T00:00:00Z`)) ? date : null;
+}
+
+/** A year argument the planner takes. */
+export function yearArg(args: ToolArgs): number | null {
+ const year = args.year;
+ return typeof year === 'number' && Number.isInteger(year) && year >= 2000 && year <= 2100 ? year : null;
+}
+
+/** The public holidays of the plan's first calendar in `year`; none while the plan shows none. */
+async function holidaysFor(plan: VacayPlanView, year: number, read: CardReader): Promise {
+ const calendar = plan.calendars[0];
+ if (!calendar) return [];
+ const value = await read('list_holidays', { country: countryOf(calendar.region), year });
+ return holidaysOf(value, calendar.region, calendar.color);
+}
+
+/**
+ * The year whose calendar grid shows `date`: a grid starts on the first of
+ * the month a leave year starts in. Null when the people of the plan split on
+ * it, since the grid is the signed-in user's and the lookup does not know who
+ * that is.
+ */
+function gridYearOf(date: string, year: number, rows: readonly VacayStatRow[]): number | null {
+ const later = rows.filter((row) => date < `${row.windowStart.slice(0, 7)}-01`).length;
+ if (later === 0) return year;
+ return later === rows.length ? year - 1 : null;
+}
+
+/** Everything a card of one day needs; null without a day or a plan (the addon off, no access). */
+export async function loadDay(args: ToolArgs, read: CardReader): Promise {
+ const date = dateArg(args);
+ if (!date) return null;
+ const plan = planOf(await read('get_vacay_plan'));
+ if (!plan) return null;
+ const year = Number(date.slice(0, 4));
+ const thisYear = statsOf(await read('get_vacay_stats', { year })) ?? [];
+ const gridYear = gridYearOf(date, year, thisYear);
+ if (gridYear === null) return null;
+ // The year before counts where a person's leave year holds the day there; a named person decides it alone.
+ const target = thisYear.find((row) => row.personId === argId(args, 'targetUserId'));
+ const earlier = target ? date < target.windowStart : thisYear.some((row) => date < row.windowStart);
+ const before = earlier ? (statsOf(await read('get_vacay_stats', { year: year - 1 })) ?? []) : [];
+ const logged = entriesOf(await read('get_vacay_entries', { year: gridYear }));
+ const holidays = earlier ? [] : await holidaysFor(plan, year, read);
+ return {
+ plan,
+ stats: [...thisYear, ...before],
+ entries: logged?.entries ?? [],
+ company: logged?.company ?? [],
+ holidays,
+ };
+}
+
+/** The days left of the year a new allowance is for. */
+export async function loadStats(args: ToolArgs, read: CardReader): Promise {
+ const year = yearArg(args);
+ if (year === null) return null;
+ const stats = statsOf(await read('get_vacay_stats', { year }));
+ return stats ? { stats } : null;
+}
diff --git a/server/src/nest/assistant/cards/vacay/vacay-reads.ts b/server/src/nest/assistant/cards/vacay/vacay-reads.ts
new file mode 100644
index 000000000..3cd87068c
--- /dev/null
+++ b/server/src/nest/assistant/cards/vacay/vacay-reads.ts
@@ -0,0 +1,206 @@
+import { clipped, finite, hexOrNull, recordOf, type Row } from '../card-values';
+import { rowsOf } from '../undo-snapshot';
+
+/**
+ * The vacation planner's tools as its cards read them: the plan
+ * (`get_vacay_plan`), the logged days of a year (`get_vacay_entries`), the
+ * days left (`get_vacay_stats`) and a country's public holidays
+ * (`list_holidays`). Every reader takes the tool's raw answer, trusts no
+ * shape and leaves out what does not parse; names are masked like a provider
+ * answer, so an e-mail address used as a user name never reaches a card.
+ */
+
+export type Fraction = 0.5 | 1;
+
+const DATE = /^\d{4}-\d{2}-\d{2}$/;
+
+export function isoDate(value: unknown): string | null {
+ return typeof value === 'string' && DATE.test(value) ? value : null;
+}
+
+/** A stored fraction as the planner counts it: below a whole day is half a day. */
+function fractionOf(value: unknown): Fraction {
+ const fraction = finite(value);
+ return fraction !== null && fraction < 1 ? 0.5 : 1;
+}
+
+function idOf(value: unknown): number | null {
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
+}
+
+export interface VacayPerson {
+ name: string;
+ color: string | null;
+}
+
+export interface VacayPlanView {
+ weekStart: 0 | 1;
+ weekendDays: number[];
+ blockWeekends: boolean;
+ companyOn: boolean;
+ people: ReadonlyMap;
+ /** The public-holiday calendars, while the plan shows public holidays. */
+ calendars: ReadonlyArray<{ region: string; color: string | null }>;
+}
+
+export interface VacayEntryRow {
+ date: string;
+ personId: number;
+ fraction: Fraction;
+ kind: 'vacation' | 'comp';
+ person: VacayPerson | null;
+}
+
+export interface VacayCompanyRow {
+ date: string;
+ fraction: Fraction;
+}
+
+export interface VacayStatRow {
+ personId: number;
+ year: number;
+ total: number;
+ used: number;
+ comp: number;
+ carried: number;
+ windowStart: string;
+ windowEnd: string;
+ person: VacayPerson | null;
+}
+
+export interface VacayHolidayRow {
+ date: string;
+ name: string;
+ color: string | null;
+}
+
+function personOf(name: unknown, color: unknown): VacayPerson | null {
+ const shown = clipped(name, 80);
+ return shown ? { name: shown, color: hexOrNull(color) } : null;
+}
+
+/**
+ * "0,6" as the weekdays it names. Only a missing or empty value is the
+ * planner's Saturday and Sunday (`isBlockedWeekend` reads it so); a value
+ * naming no real weekday is no weekend at all, there as here.
+ */
+function weekendOf(value: unknown): number[] {
+ if (typeof value !== 'string' || value === '') return [0, 6];
+ const days = value
+ .split(',')
+ .map(Number)
+ .filter((day) => Number.isInteger(day) && day >= 0 && day <= 6);
+ return [...new Set(days)].slice(0, 7);
+}
+
+/** The plan as `get_vacay_plan` answers it (`{ plan: { plan, users } }`); null for anything else. */
+export function planOf(value: unknown): VacayPlanView | null {
+ const data = recordOf(recordOf(value).plan);
+ const plan = recordOf(data.plan);
+ if (!Array.isArray(data.users) || Object.keys(plan).length === 0) return null;
+ const people = new Map();
+ for (const user of rowsOf(data.users)) {
+ const id = idOf(user.id);
+ const person = personOf(user.username, user.color);
+ if (id !== null && person) people.set(id, person);
+ }
+ const holidaysOn = plan.holidays_enabled === true || plan.holidays_enabled === 1;
+ const calendars = holidaysOn
+ ? rowsOf(plan.holiday_calendars)
+ .filter((calendar) => (calendar.type ?? 'public_holiday') === 'public_holiday')
+ .flatMap((calendar) =>
+ typeof calendar.region === 'string' && calendar.region.trim() !== ''
+ ? [{ region: calendar.region.trim(), color: hexOrNull(calendar.color) }]
+ : [],
+ )
+ : [];
+ return {
+ weekStart: plan.week_start === 0 ? 0 : 1,
+ weekendDays: weekendOf(plan.weekend_days),
+ blockWeekends: plan.block_weekends === true || plan.block_weekends === 1,
+ companyOn: plan.company_holidays_enabled === true || plan.company_holidays_enabled === 1,
+ people,
+ calendars,
+ };
+}
+
+/** The logged days and company holidays as `get_vacay_entries` answers them (`{ entries: { entries, ... } }`). */
+export function entriesOf(value: unknown): { entries: VacayEntryRow[]; company: VacayCompanyRow[] } | null {
+ const data = recordOf(recordOf(value).entries);
+ if (!Array.isArray(data.entries)) return null;
+ const entries = rowsOf(data.entries).flatMap((row): VacayEntryRow[] => {
+ const date = isoDate(row.date);
+ const personId = idOf(row.user_id);
+ if (!date || personId === null) return [];
+ return [
+ {
+ date,
+ personId,
+ fraction: fractionOf(row.fraction),
+ kind: row.kind === 'comp' ? 'comp' : 'vacation',
+ person: personOf(row.person_name, row.person_color),
+ },
+ ];
+ });
+ const company = rowsOf(data.companyHolidays).flatMap((row): VacayCompanyRow[] => {
+ const date = isoDate(row.date);
+ return date ? [{ date, fraction: fractionOf(row.fraction) }] : [];
+ });
+ return { entries, company };
+}
+
+function days(value: unknown): number {
+ const count = finite(value);
+ return count === null ? 0 : Math.min(1000, Math.max(0, count));
+}
+
+/** The days left per person as `get_vacay_stats` answers them (`{ stats: [...] }`). */
+export function statsOf(value: unknown): VacayStatRow[] | null {
+ const rows = recordOf(value).stats;
+ if (!Array.isArray(rows)) return null;
+ return rowsOf(rows).flatMap((row: Row): VacayStatRow[] => {
+ const personId = idOf(row.user_id);
+ const year = finite(row.year);
+ const windowStart = isoDate(row.window_start);
+ const windowEnd = isoDate(row.window_end);
+ if (personId === null || year === null || !windowStart || !windowEnd) return [];
+ return [
+ {
+ personId,
+ year,
+ total: days(row.total_available),
+ used: days(row.used),
+ comp: days(row.comp_used),
+ carried: days(row.carried_over),
+ windowStart,
+ windowEnd,
+ person: personOf(row.person_name, row.person_color),
+ },
+ ];
+ });
+}
+
+/**
+ * A country's public holidays as `list_holidays` answers them (nager.at
+ * rows), narrowed to `region` the way the planner clears them: a holiday of
+ * the whole country, or one of the region's own. A country that splits its
+ * holidays by region gives nothing for a calendar without one.
+ */
+export function holidaysOf(value: unknown, region: string | null, color: string | null): VacayHolidayRow[] {
+ const rows = rowsOf(recordOf(value).holidays);
+ const regional = rows.some((row) => Array.isArray(row.counties) && row.counties.length > 0);
+ const subdivision = region && region.includes('-') ? region : null;
+ if (regional && region !== null && !subdivision) return [];
+ return rows.flatMap((row): VacayHolidayRow[] => {
+ const date = isoDate(row.date);
+ const name = clipped(row.localName, 80) ?? clipped(row.name, 80);
+ const counties = Array.isArray(row.counties) ? row.counties : null;
+ const applies = row.global === true || !counties || (subdivision !== null && counties.includes(subdivision));
+ return date && name && applies ? [{ date, name, color }] : [];
+ });
+}
+
+/** The country a calendar region names: "DE-BY" is Germany. */
+export function countryOf(region: string): string {
+ return (region.split('-')[0] ?? region).toUpperCase();
+}
diff --git a/server/tests/unit/nest/assistant/cards/vacay/vacay-cards.test.ts b/server/tests/unit/nest/assistant/cards/vacay/vacay-cards.test.ts
new file mode 100644
index 000000000..d518710b7
--- /dev/null
+++ b/server/tests/unit/nest/assistant/cards/vacay/vacay-cards.test.ts
@@ -0,0 +1,560 @@
+/**
+ * The vacation planner's cards: what a held day off looks up first, what it
+ * would do to the day (taken, given back, changed, refused) and to the days
+ * left, the company holiday and the new allowance, the applied card against
+ * what the tool says it did, and the answer cards of the planner's reads,
+ * each checked against the card union.
+ */
+import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
+import type { CardReader, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
+import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
+import {
+ answerCardOf,
+ appliedCardOf,
+ ASSISTANT_WRITE_TOOLS,
+ CARD_PROMPT_LINES,
+ changeCardOf,
+ TOOL_CARDS,
+} from '../../../../../../src/nest/assistant/cards/registry';
+import { VACAY_CARDS } from '../../../../../../src/nest/assistant/cards/vacay';
+import { loadDay, loadStats, type VacayLoaded } from '../../../../../../src/nest/assistant/cards/vacay/vacay-load';
+import { entriesOf, holidaysOf, planOf, statsOf } from '../../../../../../src/nest/assistant/cards/vacay/vacay-reads';
+import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
+import { trekCardSchema, VACAY_CARD_MAX_DAYS, type VacayCard } from '@trek/shared';
+
+import { describe, expect, it } from 'vitest';
+
+const ANA = 7;
+const BEN = 8;
+
+const plan = (overrides: Record = {}) => ({
+ plan: {
+ plan: {
+ id: 1,
+ block_weekends: false,
+ weekend_days: '0,6',
+ week_start: 1,
+ company_holidays_enabled: true,
+ holidays_enabled: true,
+ holiday_calendars: [
+ { type: 'public_holiday', region: 'DE-BY', color: '#ef4444' },
+ { type: 'school_holiday', region: 'DE-BY', color: '#22c55e' },
+ ],
+ ...overrides,
+ },
+ users: [
+ { id: ANA, username: 'Ana', email: 'ana@example.com', color: '#6366f1' },
+ { id: BEN, username: 'ben@example.com', email: 'ben@example.com', color: 'red' },
+ ],
+ },
+});
+
+const entry = (user: number, date: string, fraction = 1, kind = 'vacation') => ({
+ user_id: user,
+ date,
+ fraction,
+ kind,
+ person_name: user === ANA ? 'Ana' : 'ben@example.com',
+ person_color: user === ANA ? '#6366f1' : 'red',
+});
+
+const ENTRIES = {
+ entries: {
+ entries: [
+ entry(ANA, '2026-10-26'),
+ entry(ANA, '2026-10-27', 0.5),
+ entry(BEN, '2026-10-26', 1, 'comp'),
+ entry(BEN, '2026-10-29'),
+ entry(ANA, '2026-12-21'),
+ ],
+ companyHolidays: [
+ { date: '2026-10-30', fraction: 0.5 },
+ { date: '2026-10-22', fraction: 1 },
+ ],
+ },
+};
+
+const stat = (user: number, year: number, start = `${year}-01-01`, end = `${year + 1}-01-01`) => ({
+ user_id: user,
+ person_name: user === ANA ? 'Ana' : 'ben@example.com',
+ person_color: '#6366f1',
+ year,
+ vacation_days: 28,
+ carried_over: 2,
+ total_available: 30,
+ used: user === ANA ? 12.5 : 5,
+ remaining: 0,
+ comp_used: user === BEN ? 1 : 0,
+ window_start: start,
+ window_end: end,
+});
+
+const HOLIDAYS = {
+ holidays: [
+ { date: '2026-10-03', localName: 'Tag der Deutschen Einheit', global: true, counties: null },
+ { date: '2026-11-01', localName: 'Allerheiligen', global: false, counties: ['DE-BW', 'DE-BY'] },
+ { date: '2026-10-31', localName: 'Reformationstag', global: false, counties: ['DE-BB'] },
+ { date: '2026-12-25', localName: 'Erster Weihnachtstag', global: true, counties: null },
+ ],
+};
+
+/** A reader over fixed answers, counting what it was asked. */
+function reader(values: Record) {
+ const calls: string[] = [];
+ const read: CardReader = (tool, args = {}) => {
+ const key = readKey(tool, args);
+ calls.push(key);
+ return Promise.resolve(key in values ? values[key] : null);
+ };
+ return { read, calls };
+}
+
+const CALENDAR_YEAR = {
+ [readKey('get_vacay_plan')]: plan(),
+ [readKey('get_vacay_stats', { year: 2026 })]: { stats: [stat(ANA, 2026), stat(BEN, 2026)] },
+ [readKey('get_vacay_entries', { year: 2026 })]: ENTRIES,
+ [readKey('list_holidays', { country: 'DE', year: 2026 })]: HOLIDAYS,
+};
+
+const trip = buildTripCard(
+ 1,
+ ANA,
+ {
+ trip: { title: 'Lisbon', start_date: '2026-10-27', end_date: '2026-10-28' },
+ days: [
+ { id: 31, day_number: 1, date: '2026-10-27', assignments: [], notes: [] },
+ { id: 32, day_number: 2, date: '2026-10-28', assignments: [], notes: [] },
+ ],
+ },
+ null,
+ null,
+);
+const ctx: PreviewContext = {
+ card: trip,
+ categories: new Map(),
+ locale: 'en',
+ today: '2026-10-01',
+ airport: () => null,
+};
+
+async function loaded(values: Record = CALENDAR_YEAR, args: Record = {}) {
+ const found = await loadDay({ date: '2026-10-28', ...args }, reader(values).read);
+ if (!found) throw new Error('nothing loaded');
+ return found;
+}
+
+function change(tool: string, args: Record, found: unknown): VacayCard {
+ const card = changeCardOf(tool, ctx, args, found);
+ if (card?.kind !== 'vacay') throw new Error(`no vacation card for ${tool}`);
+ return card;
+}
+
+const proposalOf = (args: Record, found: VacayLoaded) =>
+ change('toggle_vacay_entry', args, found).proposal;
+
+describe('the lookup of a held vacation day', () => {
+ it('ASTVAC-001: reads the plan, the days left, the logged days and the first calendar holidays, four reads', async () => {
+ const { read, calls } = reader(CALENDAR_YEAR);
+ const found = await loadDay({ date: '2026-10-28' }, read);
+ expect(calls).toEqual(Object.keys(CALENDAR_YEAR));
+ expect(found?.plan.calendars).toEqual([{ region: 'DE-BY', color: '#ef4444' }]);
+ expect(found?.holidays.map((holiday) => holiday.name)).toEqual([
+ 'Tag der Deutschen Einheit',
+ 'Allerheiligen',
+ 'Erster Weihnachtstag',
+ ]);
+ expect(found?.entries).toHaveLength(5);
+ expect(found?.company).toEqual([
+ { date: '2026-10-30', fraction: 0.5 },
+ { date: '2026-10-22', fraction: 1 },
+ ]);
+ // No day, no plan (the addon off): nothing to draw with, and no read for a missing day.
+ const none = reader(CALENDAR_YEAR);
+ expect(await loadDay({ date: '28.10.2026' }, none.read)).toBeNull();
+ expect(none.calls).toEqual([]);
+ expect(await loadDay({ date: '2026-10-28' }, reader({}).read)).toBeNull();
+ // Holidays switched off in the plan: no calendar, no holiday read.
+ const quiet = reader({ ...CALENDAR_YEAR, [readKey('get_vacay_plan')]: plan({ holidays_enabled: false }) });
+ expect((await loadDay({ date: '2026-10-28' }, quiet.read))?.holidays).toEqual([]);
+ expect(quiet.calls.some((call) => call.startsWith('list_holidays'))).toBe(false);
+ });
+
+ it('ASTVAC-002: a leave year that starts in April puts a March day in the year before', async () => {
+ const fiscal = {
+ [readKey('get_vacay_plan')]: plan(),
+ [readKey('get_vacay_stats', { year: 2026 })]: { stats: [stat(ANA, 2026, '2026-04-01', '2027-04-01')] },
+ [readKey('get_vacay_stats', { year: 2025 })]: { stats: [stat(ANA, 2025, '2025-04-01', '2026-04-01')] },
+ [readKey('get_vacay_entries', { year: 2025 })]: { entries: { entries: [], companyHolidays: [] } },
+ };
+ const { read, calls } = reader(fiscal);
+ const found = await loadDay({ date: '2026-03-10' }, read);
+ expect(calls).toEqual(Object.keys(fiscal));
+ if (!found) throw new Error('nothing loaded');
+ const proposal = proposalOf({ date: '2026-03-10' }, found);
+ expect(proposal).toMatchObject({
+ outcome: 'add',
+ periodYear: 2025,
+ deltas: [{ personId: ANA, year: 2025, days: 1 }],
+ });
+ const card = change('toggle_vacay_entry', { date: '2026-03-10' }, found);
+ expect(card.balances.map((balance) => balance.year)).toEqual([2025]);
+ expect(card.holidays).toEqual([]);
+ });
+
+ it('ASTVAC-005: a leave year from 6 April reads a day of 3 April from the grid that shows it', async () => {
+ // The grid of 2026 starts on 1 April, so the day is there although it counts in the leave year 2025.
+ const uk = {
+ [readKey('get_vacay_plan')]: plan(),
+ [readKey('get_vacay_stats', { year: 2026 })]: { stats: [stat(ANA, 2026, '2026-04-06', '2027-04-06')] },
+ [readKey('get_vacay_stats', { year: 2025 })]: { stats: [stat(ANA, 2025, '2025-04-06', '2026-04-06')] },
+ [readKey('get_vacay_entries', { year: 2026 })]: { entries: { entries: [entry(ANA, '2026-04-03')] } },
+ };
+ const { read, calls } = reader(uk);
+ const found = await loadDay({ date: '2026-04-03' }, read);
+ expect(calls).toEqual(Object.keys(uk));
+ if (!found) throw new Error('nothing loaded');
+ expect(proposalOf({ date: '2026-04-03' }, found)).toMatchObject({
+ outcome: 'remove',
+ periodYear: 2025,
+ deltas: [{ personId: ANA, year: 2025, days: -1 }],
+ });
+ });
+
+ it('ASTVAC-006: people whose leave years split on the day give no lookup; a named person decides alone', async () => {
+ const split = {
+ [readKey('get_vacay_plan')]: plan(),
+ [readKey('get_vacay_stats', { year: 2026 })]: {
+ stats: [stat(ANA, 2026, '2026-04-01', '2027-04-01'), stat(BEN, 2026)],
+ },
+ };
+ const { read, calls } = reader(split);
+ // Whose grid the logged days come from is not known here, so an existing day could be missed.
+ expect(await loadDay({ date: '2026-03-10' }, read)).toBeNull();
+ expect(calls).toEqual(Object.keys(split));
+ const sameMonth = {
+ [readKey('get_vacay_plan')]: plan(),
+ [readKey('get_vacay_stats', { year: 2026 })]: {
+ stats: [stat(ANA, 2026, '2026-04-06', '2027-04-06'), stat(BEN, 2026)],
+ },
+ [readKey('get_vacay_entries', { year: 2026 })]: { entries: { entries: [] } },
+ [readKey('list_holidays', { country: 'DE', year: 2026 })]: HOLIDAYS,
+ };
+ // Ben's year holds the day, so his toggle reads the holidays instead of Ana's year before.
+ const named = reader(sameMonth);
+ const found = await loadDay({ date: '2026-04-03', targetUserId: BEN }, named.read);
+ expect(named.calls).toEqual(Object.keys(sameMonth));
+ expect(found?.holidays).toHaveLength(3);
+ expect(found?.stats.map((row) => row.year)).toEqual([2026, 2026]);
+ });
+
+ it('ASTVAC-003: a new allowance reads the days left of its year only', async () => {
+ const { read, calls } = reader({ [readKey('get_vacay_stats', { year: 2027 })]: { stats: [stat(ANA, 2027)] } });
+ expect(await loadStats({ year: 2027, vacationDays: 30 }, read)).toMatchObject({ stats: [{ personId: ANA }] });
+ expect(calls).toEqual([readKey('get_vacay_stats', { year: 2027 })]);
+ expect(await loadStats({ year: 1999 }, read)).toBeNull();
+ });
+});
+
+describe('the readers of the planner tools', () => {
+ it('ASTVAC-004: odd answers read safely: the usual weekend, half of any part day, no rows that name nobody', () => {
+ const odd = planOf(plan({ weekend_days: '', week_start: 0, block_weekends: 1, company_holidays_enabled: 0 }));
+ expect(odd).toMatchObject({ weekStart: 0, weekendDays: [0, 6], blockWeekends: true, companyOn: false });
+ expect(planOf(plan({ weekend_days: '5,6,6,9' }))?.weekendDays).toEqual([5, 6]);
+ // A value naming no real weekday is no weekend to the planner either, not its usual one.
+ expect(planOf(plan({ weekend_days: '7' }))?.weekendDays).toEqual([]);
+ expect(planOf(plan({ weekend_days: null }))?.weekendDays).toEqual([0, 6]);
+ expect(planOf({ plan: { plan: {}, users: [] } })).toBeNull();
+ const rows = [entry(ANA, '2026-10-05', 0.25), { user_id: 0, date: '2026-10-06' }];
+ const logged = entriesOf({ entries: { entries: rows, companyHolidays: null } });
+ expect(logged).toEqual({
+ entries: [{ date: '2026-10-05', personId: ANA, fraction: 0.5, kind: 'vacation', person: expect.any(Object) }],
+ company: [],
+ });
+ const stats = statsOf({ stats: [stat(ANA, 2026), { user_id: BEN, year: 2026 }] });
+ expect(stats?.map((row) => row.personId)).toEqual([ANA]);
+ expect(statsOf('no plan')).toBeNull();
+ // A calendar for the whole country, where the country splits its holidays by region: none fit.
+ expect(holidaysOf(HOLIDAYS, 'DE', null)).toEqual([]);
+ expect(holidaysOf({ holidays: [{ date: '2026-05-01', name: 'Labour Day' }] }, 'PT', '#00aa00')).toEqual([
+ { date: '2026-05-01', name: 'Labour Day', color: '#00aa00' },
+ ]);
+ });
+});
+
+describe('a held day off', () => {
+ it('ASTVAC-010: takes a free day, gives back the same day, converts another size and keeps a comp day free', async () => {
+ const found = await loaded();
+ expect(proposalOf({ date: '2026-10-28' }, found)).toMatchObject({
+ personId: ANA,
+ outcome: 'add',
+ before: null,
+ periodYear: 2026,
+ notes: [],
+ deltas: [{ personId: ANA, year: 2026, days: 1 }],
+ });
+ expect(proposalOf({ date: '2026-10-26' }, found)).toMatchObject({ outcome: 'remove', deltas: [{ days: -1 }] });
+ expect(proposalOf({ date: '2026-10-27' }, found)).toMatchObject({
+ outcome: 'convert',
+ before: { fraction: 0.5, kind: 'vacation' },
+ deltas: [{ days: 0.5 }],
+ });
+ expect(proposalOf({ date: '2026-10-26', kind: 'comp' }, found)).toMatchObject({
+ outcome: 'convert',
+ kind: 'comp',
+ deltas: [{ days: -1 }],
+ });
+ expect(proposalOf({ date: '2026-10-29', targetUserId: BEN, fraction: 0.5 }, found)).toMatchObject({
+ personId: BEN,
+ outcome: 'convert',
+ deltas: [{ personId: BEN, days: -0.5 }],
+ });
+ });
+
+ it('ASTVAC-011: a blocked weekend refuses, a counted one warns, and half a company holiday leaves half a day', async () => {
+ const blocked = await loaded({ ...CALENDAR_YEAR, [readKey('get_vacay_plan')]: plan({ block_weekends: true }) });
+ expect(proposalOf({ date: '2026-10-24' }, blocked)).toMatchObject({
+ outcome: 'blocked',
+ notes: ['weekendBlocked'],
+ deltas: [{ days: 0 }],
+ });
+ const open = await loaded();
+ expect(proposalOf({ date: '2026-10-24' }, open)).toMatchObject({ outcome: 'add', notes: ['weekend'] });
+ expect(proposalOf({ date: '2026-10-03' }, open)?.notes).toEqual(['weekend', 'publicHoliday']);
+ expect(proposalOf({ date: '2026-10-30' }, open)).toMatchObject({
+ fraction: 0.5,
+ notes: ['companyHalf'],
+ deltas: [{ days: 0.5 }],
+ });
+ expect(proposalOf({ date: '2026-10-22' }, open)?.notes).toEqual(['companyHoliday']);
+ expect(proposalOf({ date: '2026-10-28', targetUserId: 99 }, open)).toMatchObject({
+ outcome: 'blocked',
+ notes: ['notInPlan'],
+ deltas: [],
+ });
+ // A target that is no id is refused by the tool, so the card does not fall back to the user's own day.
+ expect(proposalOf({ date: '2026-10-28', targetUserId: 'ben' }, open)).toMatchObject({
+ personId: ANA,
+ outcome: 'blocked',
+ notes: ['notInPlan'],
+ deltas: [],
+ });
+ });
+
+ it('ASTVAC-012: the card shows the month of the day, its people with the user first, and the leave before', async () => {
+ const card = change('toggle_vacay_entry', { date: '2026-10-29', targetUserId: BEN }, await loaded());
+ expect(trekCardSchema.parse(card)).toEqual(card);
+ expect(card.months).toEqual([{ year: 2026, month: 10 }]);
+ expect(card.people.map((person) => person.id)).toEqual([ANA, BEN]);
+ expect(card.people[1]?.name).not.toContain('example.com');
+ expect(card.people[1]?.color).toBeNull();
+ expect(card.days.map((day) => day.date)).not.toContain('2026-12-21');
+ expect(card.days).toContainEqual({ date: '2026-10-26', personId: BEN, fraction: 1, kind: 'comp' });
+ expect(card.company).toHaveLength(2);
+ expect(card.holidays).toEqual([{ date: '2026-10-03', name: 'Tag der Deutschen Einheit', color: '#ef4444' }]);
+ expect(card.tripDates).toEqual(['2026-10-27', '2026-10-28']);
+ expect(card.balances).toEqual([
+ {
+ personId: BEN,
+ year: 2026,
+ total: 30,
+ carried: 2,
+ used: 5,
+ comp: 1,
+ totalAfter: null,
+ windowStart: '2026-01-01',
+ windowEnd: '2027-01-01',
+ },
+ ]);
+ expect(card).toMatchObject({ weekStart: 1, weekendDays: [0, 6], otherDays: 0 });
+ });
+
+ it('ASTVAC-013: the change card says what the toggle does; without its lookup it still names the day', async () => {
+ const found = await loaded();
+ const remove = describeToolCall('toggle_vacay_entry', 'v1', { date: '2026-10-26' }, ctx, found);
+ expect(remove).toMatchObject({ verbKey: 'removeVacationDay', subject: 'Mon, Oct 26', domain: 'vacay', rows: [] });
+ expect(remove.card?.kind).toBe('vacay');
+ const convert = describeToolCall('toggle_vacay_entry', 'v2', { date: '2026-10-27' }, ctx, found);
+ expect(convert.verbKey).toBe('changeVacationDay');
+ const bare = describeToolCall('toggle_vacay_entry', 'v3', { date: '2026-10-26' }, ctx, null);
+ expect(bare).toMatchObject({ verbKey: 'addVacationDay', subject: 'Mon, Oct 26', undoable: false });
+ expect(bare.card).toBeUndefined();
+ expect(appliedChange('toggle_vacay_entry', 'v1', {}, { action: 'removed' }, null, null)).toMatchObject({
+ entity: 'vacay',
+ ids: [],
+ });
+ });
+
+ it('ASTVAC-014: the applied card follows what the tool did, which can differ from the held card hours later', async () => {
+ const found = await loaded();
+ const args = { date: '2026-10-28' };
+ const preview = describeToolCall('toggle_vacay_entry', 'v1', args, ctx, found);
+ const applied = (result: unknown) => {
+ const card = appliedCardOf('toggle_vacay_entry', { args, result, preview });
+ return card?.kind === 'vacay' ? card : null;
+ };
+ // As foreseen: the held card stands.
+ expect(applied({ action: 'added', fraction: 1, kind: 'vacation' })).toEqual(preview.card);
+ // Someone logged the day meanwhile: the toggle cleared it, and gave back the day it was asked with.
+ expect(applied({ action: 'removed' })?.proposal).toMatchObject({
+ outcome: 'remove',
+ deltas: [{ personId: ANA, year: 2026, days: -1 }],
+ });
+ // Half a company holiday came meanwhile: half a day was taken.
+ expect(applied({ action: 'added', fraction: 0.5, kind: 'vacation' })?.proposal).toMatchObject({
+ outcome: 'add',
+ fraction: 0.5,
+ deltas: [{ days: 0.5 }],
+ });
+ // A day the card did not know was converted: what it was before is unknown, so the days left stay.
+ expect(applied({ action: 'updated', fraction: 1, kind: 'vacation' })?.proposal).toMatchObject({
+ outcome: 'convert',
+ deltas: [],
+ });
+ // A refusal names nothing done, and a preview without its card gives none.
+ expect(applied({ error: 'weekend_blocked' })).toBeNull();
+ const bare = { ...preview, card: null };
+ expect(appliedCardOf('toggle_vacay_entry', { args, result: { action: 'added' }, preview: bare })).toBeNull();
+ const shut = { date: '2026-10-26' };
+ const company = describeToolCall('toggle_company_holiday', 'c1', shut, ctx, found);
+ const removed = { args: shut, result: { action: 'removed' }, preview: company };
+ const done = appliedCardOf('toggle_company_holiday', removed);
+ expect(done?.kind === 'vacay' && done.proposal).toMatchObject({ outcome: 'remove', deltas: [] });
+ const added = { args: shut, result: { action: 'added', fraction: 1 }, preview: company };
+ expect(appliedCardOf('toggle_company_holiday', added)).toEqual(company.card);
+ // A known half day turned into a comp day instead of a whole one: it gives the half day back.
+ const half = { date: '2026-10-27' };
+ const convert = describeToolCall('toggle_vacay_entry', 'v4', half, ctx, found);
+ const comp = { args: half, result: { action: 'updated', fraction: 1, kind: 'comp' }, preview: convert };
+ const changed = appliedCardOf('toggle_vacay_entry', comp);
+ expect(changed?.kind === 'vacay' && changed.proposal).toMatchObject({ kind: 'comp', deltas: [{ days: -0.5 }] });
+ });
+});
+
+describe('a held company holiday and allowance', () => {
+ it('ASTVAC-020: a whole company holiday gives back what was logged that day, a half one half of a whole day', async () => {
+ const found = await loaded();
+ const whole = change('toggle_company_holiday', { date: '2026-10-26' }, found);
+ expect(whole.proposal).toMatchObject({ personId: null, kind: 'company', fraction: 1, outcome: 'add' });
+ // Ana's day counted, Ben's comp day did not.
+ expect(whole.proposal?.deltas).toEqual([{ personId: ANA, year: 2026, days: -1 }]);
+ expect(whole.balances.map((balance) => balance.personId)).toEqual([ANA]);
+ const half = change('toggle_company_holiday', { date: '2026-10-27', half: true }, found);
+ expect(half.proposal?.deltas).toEqual([]);
+ const halfOfWhole = change('toggle_company_holiday', { date: '2026-10-29', half: true }, found);
+ expect(halfOfWhole.proposal?.deltas).toEqual([{ personId: BEN, year: 2026, days: -0.5 }]);
+ const off = change('toggle_company_holiday', { date: '2026-10-30', half: true }, found);
+ expect(off.proposal).toMatchObject({ outcome: 'remove', deltas: [] });
+ const larger = change('toggle_company_holiday', { date: '2026-10-30' }, found);
+ expect(larger.proposal).toMatchObject({ outcome: 'convert', before: { fraction: 0.5, kind: 'company' } });
+ const shut = { date: '2026-10-26', note: 'Shut' };
+ const described = describeToolCall('toggle_company_holiday', 'c1', shut, ctx, found);
+ expect(described).toMatchObject({ verbKey: 'setCompanyHoliday', rows: [{ labelKey: 'notes', after: 'Shut' }] });
+ expect(whole.proposal?.notes).toEqual([]);
+ // Company holidays off in the plan: the planner would refuse it, the tool writes it, the card warns.
+ const companyOff = plan({ company_holidays_enabled: false });
+ const off = await loaded({ ...CALENDAR_YEAR, [readKey('get_vacay_plan')]: companyOff });
+ const unseen = change('toggle_company_holiday', { date: '2026-10-03' }, off);
+ expect(unseen.proposal?.notes).toEqual(['companyOff', 'publicHoliday']);
+ });
+
+ it("ASTVAC-021: a new allowance shows the user's leave with the total it gives, carry-over included", async () => {
+ const found = await loadStats(
+ { year: 2026 },
+ reader({ [readKey('get_vacay_stats', { year: 2026 })]: { stats: [stat(ANA, 2026), stat(BEN, 2026)] } }).read,
+ );
+ const card = change('update_vacay_stats', { year: 2026, vacationDays: 31 }, found);
+ expect(card.balances).toEqual([expect.objectContaining({ personId: ANA, total: 30, carried: 2, totalAfter: 33 })]);
+ expect(card.people.map((person) => person.id)).toEqual([ANA]);
+ expect(changeCardOf('update_vacay_stats', ctx, { year: 2026, vacationDays: -1 }, found)).toBeNull();
+ expect(changeCardOf('update_vacay_stats', ctx, { year: 2026, vacationDays: 31 }, null)).toBeNull();
+ expect(describeToolCall('update_vacay_stats', 's1', { year: 2026, vacationDays: 31 }, ctx, found)).toMatchObject({
+ verbKey: 'setVacationAllowance',
+ subject: '2026',
+ rows: [{ labelKey: 'quantity', after: '31' }],
+ });
+ });
+
+ it('ASTVAC-022: the package registers its writes, reads and one prompt line, and no undo', () => {
+ expect(ASSISTANT_WRITE_TOOLS.toggle_vacay_entry).toEqual({ verbKey: 'addVacationDay', entity: 'vacay' });
+ expect(ASSISTANT_WRITE_TOOLS.toggle_company_holiday?.verbKey).toBe('setCompanyHoliday');
+ expect(ASSISTANT_WRITE_TOOLS.update_vacay_stats?.verbKey).toBe('setVacationAllowance');
+ for (const tool of ['get_vacay_entries', 'get_vacay_stats', 'list_holidays']) {
+ expect(TOOL_CARDS[tool]?.answer, tool).toBeTypeOf('function');
+ }
+ expect(TOOL_CARDS.toggle_vacay_entry?.undo).toBeUndefined();
+ expect(TOOL_CARDS.toggle_vacay_entry?.applied).toBeTypeOf('function');
+ expect(TOOL_CARDS.toggle_company_holiday?.applied).toBeTypeOf('function');
+ expect(CARD_PROMPT_LINES).toContain(VACAY_CARDS.prompt?.[0]);
+ });
+});
+
+describe('the answer cards', () => {
+ const answer = (tool: string, value: unknown, args: Record = {}, today = '2026-10-01') =>
+ answerCardOf(tool, { toolCallId: 'r1', args, value, card: trip, today });
+ /** The same read in a trip without dates. */
+ const undated = { ...trip, days: [], startDate: null, endDate: null };
+ const away = (tool: string, value: unknown, args: Record, today: string) =>
+ answerCardOf(tool, { toolCallId: 'r2', args, value, card: undated, today });
+
+ it("ASTVAC-030: a year's days off show on the trip's months, else the coming ones, else today or the year's start", () => {
+ const onTrip = answer('get_vacay_entries', ENTRIES, { year: 2026 });
+ if (onTrip?.kind !== 'vacay') throw new Error('no card');
+ expect(onTrip.months).toEqual([{ year: 2026, month: 10 }]);
+ expect(onTrip.people.map((person) => person.id)).toEqual([ANA, BEN]);
+ expect(onTrip.otherDays).toBe(1);
+ expect(onTrip.tripDates).toEqual(['2026-10-27', '2026-10-28']);
+ expect(onTrip.year).toBe(2026);
+ const unasked = answer('get_vacay_entries', ENTRIES);
+ expect(unasked?.kind === 'vacay' && 'year' in unasked).toBe(false);
+ const coming = away('get_vacay_entries', ENTRIES, { year: 2026 }, '2026-11-15');
+ expect(coming?.kind === 'vacay' && coming.months).toEqual([{ year: 2026, month: 12 }]);
+ const empty = { entries: { entries: [], companyHolidays: [] } };
+ const today = away('get_vacay_entries', empty, { year: 2026 }, '2026-05-04');
+ expect(today?.kind === 'vacay' && today.months).toEqual([{ year: 2026, month: 5 }]);
+ const later = away('get_vacay_entries', empty, { year: 2028 }, '2026-05-04');
+ expect(later?.kind === 'vacay' && later.months).toEqual([{ year: 2028, month: 1 }]);
+ expect(answer('get_vacay_entries', 'User not in plan')).toBeNull();
+ });
+
+ it("ASTVAC-031: a busy plan keeps the user's days first and stays under the card size", () => {
+ const people = [ANA, BEN, 9, 10, 11, 12, 13, 14];
+ const rows = people.flatMap((user) =>
+ Array.from({ length: 31 }, (_, day) => entry(user, `2026-10-${String(day + 1).padStart(2, '0')}`)),
+ );
+ const card = answer('get_vacay_entries', { entries: { entries: rows, companyHolidays: [] } }, { year: 2026 });
+ if (card?.kind !== 'vacay') throw new Error('no card');
+ expect(card.days).toHaveLength(VACAY_CARD_MAX_DAYS);
+ expect(card.days.filter((day) => day.personId === ANA)).toHaveLength(31);
+ expect(JSON.stringify(card).length).toBeLessThan(24_000);
+ // Three whole months of trip days and of company holidays fit the card.
+ const long = Array.from({ length: 92 }, (_, index) => {
+ const date = new Date(Date.UTC(2026, 9, 1 + index)).toISOString().slice(0, 10);
+ return { id: 100 + index, day_number: index + 1, date, assignments: [], notes: [] };
+ });
+ const trek = buildTripCard(2, ANA, { trip: { title: 'Long', start_date: '2026-10-01' }, days: long }, null, null);
+ const company = long.map((day) => ({ date: day.date, fraction: 1 }));
+ const value = { entries: { entries: [], companyHolidays: [...company, ...company] } };
+ const input = { toolCallId: 'r3', args: { year: 2026 }, value, card: trek, today: '2026-10-01' };
+ const busy = answerCardOf('get_vacay_entries', input);
+ if (busy?.kind !== 'vacay') throw new Error('no card');
+ expect(busy.months).toHaveLength(3);
+ expect(busy.tripDates).toHaveLength(92);
+ expect(busy.company).toHaveLength(92);
+ });
+
+ it('ASTVAC-032: the days left list the user first; holidays show on the trip months or the coming ones', () => {
+ const stats = answer('get_vacay_stats', { stats: [stat(BEN, 2026), stat(ANA, 2026)] }, { year: 2026 });
+ if (stats?.kind !== 'vacay') throw new Error('no card');
+ expect(stats.balances.map((balance) => balance.personId)).toEqual([ANA, BEN]);
+ expect(stats.months).toEqual([]);
+ expect(answer('get_vacay_stats', { stats: [] })).toBeNull();
+ const holidays = answer('list_holidays', HOLIDAYS, { country: 'DE', year: 2026 });
+ if (holidays?.kind !== 'vacay') throw new Error('no card');
+ // Only the holidays of the whole country: the answer knows no region.
+ expect(holidays.holidays).toEqual([{ date: '2026-10-03', name: 'Tag der Deutschen Einheit', color: null }]);
+ expect(holidays.months).toEqual([{ year: 2026, month: 10 }]);
+ const coming = away('list_holidays', HOLIDAYS, { country: 'DE', year: 2026 }, '2026-10-10');
+ expect(coming?.kind === 'vacay' && coming.months).toEqual([{ year: 2026, month: 12 }]);
+ expect(answer('list_holidays', { holidays: [] })).toBeNull();
+ });
+});
diff --git a/shared/src/assistant/cards/vacay.card.spec.ts b/shared/src/assistant/cards/vacay.card.spec.ts
new file mode 100644
index 000000000..b652dfc8d
--- /dev/null
+++ b/shared/src/assistant/cards/vacay.card.spec.ts
@@ -0,0 +1,88 @@
+import { ASSISTANT_CHANGE_VERB_KEYS, assistantAppliedSchema } from '../assistant.schema';
+import { storedTrekCardSchema, trekCardSchema } from './index';
+import { VACAY_CARD_MAX_DATES, VACAY_CARD_MAX_DAYS, vacayCardSchema, type VacayCard } from './vacay.card';
+import { VACAY_APPLIED_ENTITIES, VACAY_VERB_KEYS } from './vacay.contract';
+
+import { describe, expect, it } from 'vitest';
+
+/** A change card of one day off, as the server builds it. */
+const card = (): VacayCard => ({
+ kind: 'vacay',
+ months: [{ year: 2026, month: 10 }],
+ weekStart: 1,
+ weekendDays: [0, 6],
+ people: [{ id: 7, name: 'Ana', avatarKey: null, color: '#6366f1' }],
+ days: [{ date: '2026-10-26', personId: 7, fraction: 1, kind: 'vacation' }],
+ company: [{ date: '2026-10-30', fraction: 0.5 }],
+ holidays: [{ date: '2026-10-03', name: 'Tag der Deutschen Einheit', color: null }],
+ tripDates: ['2026-10-27'],
+ proposal: {
+ date: '2026-10-27',
+ personId: 7,
+ kind: 'vacation',
+ fraction: 1,
+ outcome: 'add',
+ before: null,
+ periodYear: 2026,
+ notes: [],
+ deltas: [{ personId: 7, year: 2026, days: 1 }],
+ },
+ balances: [
+ {
+ personId: 7,
+ year: 2026,
+ total: 30,
+ used: 12.5,
+ comp: 0,
+ totalAfter: null,
+ windowStart: '2026-01-01',
+ windowEnd: '2027-01-01',
+ },
+ ],
+ otherDays: 0,
+});
+
+describe('the vacation card', () => {
+ it('VACAYCARD-001: a change card and an answer card read as themselves in the union', () => {
+ expect(trekCardSchema.parse(card())).toEqual(card());
+ const answer = { ...card(), proposal: null, months: [] };
+ expect(storedTrekCardSchema.parse(answer)).toEqual(answer);
+ });
+
+ it('VACAYCARD-002: a colour that is no hex, half a half day or an outcome it does not know reads as no card', () => {
+ const people = [{ id: 7, name: 'Ana', avatarKey: null, color: 'url(x)' }];
+ expect(storedTrekCardSchema.parse({ ...card(), people })).toBeNull();
+ const days = [{ date: '2026-10-26', personId: 7, fraction: 0.25, kind: 'vacation' }];
+ expect(storedTrekCardSchema.parse({ ...card(), days })).toBeNull();
+ const proposal = { ...card().proposal, outcome: 'swap' };
+ expect(storedTrekCardSchema.parse({ ...card(), proposal })).toBeNull();
+ });
+
+ it('VACAYCARD-003: the lists are bounded, and a field a later build adds is dropped on read', () => {
+ const many = Array.from({ length: VACAY_CARD_MAX_DAYS + 1 }, () => card().days[0]);
+ expect(vacayCardSchema.safeParse({ ...card(), days: many }).success).toBe(false);
+ const months = Array.from({ length: 4 }, () => ({ year: 2026, month: 1 }));
+ expect(vacayCardSchema.safeParse({ ...card(), months }).success).toBe(false);
+ // Three long months of trip days and company holidays fit; one date more does not.
+ const dates = (count: number) => Array.from({ length: count }, () => '2026-10-27');
+ expect(vacayCardSchema.safeParse({ ...card(), tripDates: dates(VACAY_CARD_MAX_DATES) }).success).toBe(true);
+ expect(vacayCardSchema.safeParse({ ...card(), tripDates: dates(VACAY_CARD_MAX_DATES + 1) }).success).toBe(false);
+ const company = dates(VACAY_CARD_MAX_DATES).map((date) => ({ date, fraction: 1 }));
+ expect(vacayCardSchema.safeParse({ ...card(), company }).success).toBe(true);
+ expect(storedTrekCardSchema.parse({ ...card(), later: true })).toEqual(card());
+ });
+
+ it('VACAYCARD-005: the carried-over days and the year asked for are optional, so older cards still read', () => {
+ const balances = [{ ...card().balances[0], carried: 2 }];
+ expect(vacayCardSchema.parse({ ...card(), balances, year: 2026 })).toMatchObject({ balances, year: 2026 });
+ expect(vacayCardSchema.safeParse({ ...card(), year: 1999 }).success).toBe(false);
+ const proposal = { ...card().proposal, kind: 'company', personId: null, notes: ['companyOff'] };
+ expect(vacayCardSchema.safeParse({ ...card(), proposal }).success).toBe(true);
+ });
+
+ it('VACAYCARD-004: its verbs and its entity join the shared enums', () => {
+ for (const verb of VACAY_VERB_KEYS) expect(ASSISTANT_CHANGE_VERB_KEYS).toContain(verb);
+ const applied = { toolCallId: 'c1', entity: VACAY_APPLIED_ENTITIES[0], ids: [], dayId: null, undoUntil: null };
+ expect(assistantAppliedSchema.parse(applied).entity).toBe('vacay');
+ });
+});
diff --git a/shared/src/assistant/cards/vacay.card.ts b/shared/src/assistant/cards/vacay.card.ts
index 425b3f1aa..1dd10be6a 100644
--- a/shared/src/assistant/cards/vacay.card.ts
+++ b/shared/src/assistant/cards/vacay.card.ts
@@ -1,8 +1,129 @@
+import { idSchema } from '../../common/primitives.schema';
+import { cardDateSchema, cardHexColorSchema, cardPersonSchema, cardText } from './card-parts';
+
import { z } from 'zod';
/**
- * Vacation days on a month calendar, with the days left. Only its kind so far; the vacation planner
- * cards give it its fields.
+ * Vacation days on a month calendar, with the days left. An answer card shows
+ * what a read found (the days off of a year, the days left, the public
+ * holidays); a change card shows the one day a held write would take or give
+ * back, on its month, with the leave before it. The people are the vacation
+ * plan's, which need not be the trip's: a fused plan holds partners too.
*/
-export const vacayCardSchema = z.object({ kind: z.literal('vacay') });
+
+const fractionSchema = z.union([z.literal(0.5), z.literal(1)]);
+const yearSchema = z.number().int().min(2000).max(2100);
+/** Days are counted in halves, so a count is a multiple of 0.5. */
+const daysSchema = z.number().min(0).max(1000);
+
+/** What a day off is: leave that counts, a comp day that does not, or a holiday the company gives everyone. */
+export const VACAY_DAY_KINDS = ['vacation', 'comp', 'company'] as const;
+
+/** What a held toggle would do to its day. */
+export const VACAY_PROPOSAL_OUTCOMES = ['add', 'remove', 'convert', 'blocked'] as const;
+
+/** What a held toggle should know about its day before it is applied. */
+export const VACAY_PROPOSAL_NOTES = [
+ 'weekendBlocked',
+ 'weekend',
+ 'publicHoliday',
+ 'companyHoliday',
+ 'companyHalf',
+ 'notInPlan',
+ 'companyOff',
+] as const;
+
+/** Someone of the vacation plan: their own colour colours their days. */
+export const vacayCardPersonSchema = cardPersonSchema.extend({ color: cardHexColorSchema.nullable() });
+
+/** A logged day off of one person. */
+export const vacayCardDaySchema = z.object({
+ date: cardDateSchema,
+ personId: idSchema,
+ fraction: fractionSchema,
+ kind: z.enum(['vacation', 'comp']),
+});
+
+/** A public holiday, in its calendar's colour when the plan has one. */
+export const vacayCardHolidaySchema = z.object({
+ date: cardDateSchema,
+ name: cardText(80),
+ color: cardHexColorSchema.nullable(),
+});
+
+/** The day a held write would change, and what that does to the days left. */
+export const vacayCardProposalSchema = z.object({
+ date: cardDateSchema,
+ /** Null for a company holiday, which is the whole plan's. */
+ personId: idSchema.nullable(),
+ kind: z.enum(VACAY_DAY_KINDS),
+ fraction: fractionSchema,
+ outcome: z.enum(VACAY_PROPOSAL_OUTCOMES),
+ /** What the day was before, when it was logged already. */
+ before: z.object({ fraction: fractionSchema, kind: z.enum(VACAY_DAY_KINDS) }).nullable(),
+ /** The leave year the day counts in, which runs from the person's own start day. */
+ periodYear: yearSchema.nullable(),
+ notes: z.array(z.enum(VACAY_PROPOSAL_NOTES)).max(VACAY_PROPOSAL_NOTES.length),
+ /** The change to each person's used days in a leave year; a company holiday can give several people days back. */
+ deltas: z.array(z.object({ personId: idSchema, year: yearSchema, days: z.number().min(-1).max(1) })).max(8),
+});
+
+/** One person's leave in one leave year, as it stands before the change. */
+export const vacayCardBalanceSchema = z.object({
+ personId: idSchema,
+ year: yearSchema,
+ /** The allowance with what was carried over. */
+ total: daysSchema,
+ /** What was carried over into the year, so the allowance alone is `total - carried`; absent on older cards. */
+ carried: daysSchema.optional(),
+ used: daysSchema,
+ comp: daysSchema,
+ /** The new total a change of the allowance would give; null for anything else. */
+ totalAfter: daysSchema.nullable(),
+ windowStart: cardDateSchema,
+ windowEnd: cardDateSchema,
+});
+
+/**
+ * The bounds keep the heaviest card (three busy months of a plan of eight)
+ * under MAX_CARD_CHARS; the server keeps the days of the months it shows
+ * first and drops the rest.
+ */
+export const VACAY_CARD_MAX_DAYS = 150;
+export const VACAY_CARD_MAX_HOLIDAYS = 30;
+/** Every date of the three months a card shows at most: the trip days and the company holidays. */
+export const VACAY_CARD_MAX_DATES = 93;
+
+export const vacayCardSchema = z.object({
+ kind: z.literal('vacay'),
+ /** The months the calendar shows, in order; none for a card of days left only. */
+ months: z.array(z.object({ year: yearSchema, month: z.number().int().min(1).max(12) })).max(3),
+ /** The first column: 0 for Sunday, 1 for Monday. */
+ weekStart: z.union([z.literal(0), z.literal(1)]),
+ /** The weekdays that count as weekend, 0 for Sunday. */
+ weekendDays: z.array(z.number().int().min(0).max(6)).max(7),
+ people: z.array(vacayCardPersonSchema).max(8),
+ /** The logged days inside `months`. */
+ days: z.array(vacayCardDaySchema).max(VACAY_CARD_MAX_DAYS),
+ company: z.array(z.object({ date: cardDateSchema, fraction: fractionSchema })).max(VACAY_CARD_MAX_DATES),
+ holidays: z.array(vacayCardHolidaySchema).max(VACAY_CARD_MAX_HOLIDAYS),
+ /** The dated days of this trip inside `months`. */
+ tripDates: z.array(cardDateSchema).max(VACAY_CARD_MAX_DATES),
+ /** A change card's one day; null on an answer. */
+ proposal: vacayCardProposalSchema.nullable(),
+ balances: z.array(vacayCardBalanceSchema).max(8),
+ /** The days off of the signed-in user in the year outside `months`. */
+ otherDays: daysSchema,
+ /** The leave year a read of the days off asked for, which Vacay opens on; absent elsewhere. */
+ year: yearSchema.optional(),
+});
+
export type VacayCard = z.infer;
+export type VacayCardPerson = z.infer;
+export type VacayCardDay = z.infer;
+export type VacayCardHoliday = z.infer;
+export type VacayCardProposal = z.infer;
+export type VacayCardBalance = z.infer;
+export type VacayProposalNote = (typeof VACAY_PROPOSAL_NOTES)[number];
+export type VacayProposalOutcome = (typeof VACAY_PROPOSAL_OUTCOMES)[number];
+export type VacayDayKind = (typeof VACAY_DAY_KINDS)[number];
diff --git a/shared/src/assistant/cards/vacay.contract.ts b/shared/src/assistant/cards/vacay.contract.ts
index b6a65fa7a..a934d8192 100644
--- a/shared/src/assistant/cards/vacay.contract.ts
+++ b/shared/src/assistant/cards/vacay.contract.ts
@@ -10,5 +10,13 @@ export * from './vacay.card';
* or entity is added here and nowhere else.
*/
export const VACAY_CARD_SCHEMAS = [vacayCardSchema] as const;
-export const VACAY_VERB_KEYS = [] as const;
-export const VACAY_APPLIED_ENTITIES = [] as const;
+/** A day off taken, given back or changed, a company holiday set, the allowance changed. */
+export const VACAY_VERB_KEYS = [
+ 'addVacationDay',
+ 'removeVacationDay',
+ 'changeVacationDay',
+ 'setCompanyHoliday',
+ 'setVacationAllowance',
+] as const;
+/** A day or the allowance of the vacation plan: the receipt opens the vacation planner. */
+export const VACAY_APPLIED_ENTITIES = ['vacay'] as const;
diff --git a/shared/src/i18n/ar/assistantVacay.ts b/shared/src/i18n/ar/assistantVacay.ts
index 0a509629a..0b6e4be4f 100644
--- a/shared/src/i18n/ar/assistantVacay.ts
+++ b/shared/src/i18n/ar/assistantVacay.ts
@@ -1,4 +1,80 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'أخذ يوم إجازة',
+ 'assistant.change.verb.removeVacationDay': 'إلغاء يوم إجازة',
+ 'assistant.change.verb.changeVacationDay': 'تغيير يوم إجازة',
+ 'assistant.change.verb.setCompanyHoliday': 'تحديد عطلة الشركة',
+ 'assistant.change.verb.setVacationAllowance': 'تغيير رصيد الإجازة',
+ 'assistant.card.vacay.label': 'تقويم الإجازات',
+ 'assistant.card.vacay.fullDay': 'يوم كامل',
+ 'assistant.card.vacay.halfDay': 'نصف يوم',
+ 'assistant.card.vacay.compDay': 'يوم تعويضي',
+ 'assistant.card.vacay.daysLeft': 'الأيام المتبقية: {count}',
+ 'assistant.card.vacay.daysLeft.zero': 'الأيام المتبقية: {count}',
+ 'assistant.card.vacay.daysLeft.one': 'الأيام المتبقية: {count}',
+ 'assistant.card.vacay.daysLeft.two': 'الأيام المتبقية: {count}',
+ 'assistant.card.vacay.daysLeft.few': 'الأيام المتبقية: {count}',
+ 'assistant.card.vacay.daysLeft.many': 'الأيام المتبقية: {count}',
+ 'assistant.card.vacay.usedDays': 'الأيام المستخدمة: {count}',
+ 'assistant.card.vacay.usedDays.zero': 'الأيام المستخدمة: {count}',
+ 'assistant.card.vacay.usedDays.one': 'الأيام المستخدمة: {count}',
+ 'assistant.card.vacay.usedDays.two': 'الأيام المستخدمة: {count}',
+ 'assistant.card.vacay.usedDays.few': 'الأيام المستخدمة: {count}',
+ 'assistant.card.vacay.usedDays.many': 'الأيام المستخدمة: {count}',
+ 'assistant.card.vacay.usedOf': '{used} مستخدم من {total}',
+ 'assistant.card.vacay.uses': 'أيام ستُستخدم: {count}',
+ 'assistant.card.vacay.uses.zero': 'أيام ستُستخدم: {count}',
+ 'assistant.card.vacay.uses.one': 'أيام ستُستخدم: {count}',
+ 'assistant.card.vacay.uses.two': 'أيام ستُستخدم: {count}',
+ 'assistant.card.vacay.uses.few': 'أيام ستُستخدم: {count}',
+ 'assistant.card.vacay.uses.many': 'أيام ستُستخدم: {count}',
+ 'assistant.card.vacay.givesBack': 'أيام تُعاد: {count}',
+ 'assistant.card.vacay.givesBack.zero': 'أيام تُعاد: {count}',
+ 'assistant.card.vacay.givesBack.one': 'أيام تُعاد: {count}',
+ 'assistant.card.vacay.givesBack.two': 'أيام تُعاد: {count}',
+ 'assistant.card.vacay.givesBack.few': 'أيام تُعاد: {count}',
+ 'assistant.card.vacay.givesBack.many': 'أيام تُعاد: {count}',
+ 'assistant.card.vacay.over': 'أيام فوق الرصيد: {count}',
+ 'assistant.card.vacay.over.zero': 'أيام فوق الرصيد: {count}',
+ 'assistant.card.vacay.over.one': 'أيام فوق الرصيد: {count}',
+ 'assistant.card.vacay.over.two': 'أيام فوق الرصيد: {count}',
+ 'assistant.card.vacay.over.few': 'أيام فوق الرصيد: {count}',
+ 'assistant.card.vacay.over.many': 'أيام فوق الرصيد: {count}',
+ 'assistant.card.vacay.compDays': 'أيام تعويضية: {count}',
+ 'assistant.card.vacay.compDays.zero': 'أيام تعويضية: {count}',
+ 'assistant.card.vacay.compDays.one': 'أيام تعويضية: {count}',
+ 'assistant.card.vacay.compDays.two': 'أيام تعويضية: {count}',
+ 'assistant.card.vacay.compDays.few': 'أيام تعويضية: {count}',
+ 'assistant.card.vacay.compDays.many': 'أيام تعويضية: {count}',
+ 'assistant.card.vacay.otherDays': 'أيام إجازة أخرى هذا العام: {count}',
+ 'assistant.card.vacay.otherDays.zero': 'أيام إجازة أخرى هذا العام: {count}',
+ 'assistant.card.vacay.otherDays.one': 'أيام إجازة أخرى هذا العام: {count}',
+ 'assistant.card.vacay.otherDays.two': 'أيام إجازة أخرى هذا العام: {count}',
+ 'assistant.card.vacay.otherDays.few': 'أيام إجازة أخرى هذا العام: {count}',
+ 'assistant.card.vacay.otherDays.many': 'أيام إجازة أخرى هذا العام: {count}',
+ 'assistant.card.vacay.allowance': 'الاستحقاق',
+ 'assistant.card.vacay.meter': '{used} مستخدم، {planned} مخطط، {left} متبقٍ من {total}',
+ 'assistant.card.vacay.weekendBlocked': 'عطلة نهاية الأسبوع، محجوبة في خطتك',
+ 'assistant.card.vacay.weekend': 'يوم عطلة نهاية الأسبوع، يُحسب إجازة',
+ 'assistant.card.vacay.publicHoliday': 'عطلة رسمية، لا حاجة لإجازة',
+ 'assistant.card.vacay.companyHoliday': 'عطلة شركة، لا حاجة لإجازة',
+ 'assistant.card.vacay.companyHalf': 'نصف عطلة شركة، يتبقى نصف يوم',
+ 'assistant.card.vacay.notInPlan': 'ليس ضمن خطة إجازاتك',
+ 'assistant.card.vacay.companyOff': 'عطل الشركة متوقفة في هذه الخطة',
+ 'assistant.card.vacay.twice': 'مُدرج مرتين، يلغي أحدهما الآخر',
+ 'assistant.card.vacay.trip': 'هذه الرحلة',
+ 'assistant.card.vacay.tapToSkip': 'اضغط للاستبعاد',
+ 'assistant.card.vacay.tapToTake': 'اضغط للتضمين',
+ 'assistant.card.vacay.open': 'فتح في Vacay',
+ 'assistant.step.vacayEntries': 'قراءة أيام إجازتك',
+ 'assistant.step.vacayStats': 'حساب الأيام المتبقية',
+ 'assistant.step.vacayHolidays': 'البحث عن العطل الرسمية',
+ 'assistant.tool.add_vacay_year': 'إضافة سنة إلى Vacay',
+ 'assistant.tool.delete_vacay_year': 'إزالة سنة من Vacay',
+ 'assistant.tool.update_vacay_plan': 'تغيير إعدادات Vacay',
+ 'assistant.tool.set_vacay_color': 'تغيير لونك في Vacay',
+ 'assistant.tool.add_holiday_calendar': 'إضافة تقويم عطلات',
+ 'assistant.tool.delete_holiday_calendar': 'إزالة تقويم عطلات',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/az/assistantVacay.ts b/shared/src/i18n/az/assistantVacay.ts
index 0a509629a..479f7a120 100644
--- a/shared/src/i18n/az/assistantVacay.ts
+++ b/shared/src/i18n/az/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Məzuniyyət günü götürmək',
+ 'assistant.change.verb.removeVacationDay': 'Məzuniyyət gününü ləğv etmək',
+ 'assistant.change.verb.changeVacationDay': 'Məzuniyyət gününü dəyişmək',
+ 'assistant.change.verb.setCompanyHoliday': 'Şirkət tətili təyin etmək',
+ 'assistant.change.verb.setVacationAllowance': 'Məzuniyyət limitini dəyişmək',
+ 'assistant.card.vacay.label': 'Məzuniyyət təqvimi',
+ 'assistant.card.vacay.fullDay': 'Tam gün',
+ 'assistant.card.vacay.halfDay': 'Yarım gün',
+ 'assistant.card.vacay.compDay': 'Kompensasiya günü',
+ 'assistant.card.vacay.daysLeft': '{count} gün qalıb',
+ 'assistant.card.vacay.daysLeft.one': '{count} gün qalıb',
+ 'assistant.card.vacay.usedDays': '{count} gün istifadə olunub',
+ 'assistant.card.vacay.usedDays.one': '{count} gün istifadə olunub',
+ 'assistant.card.vacay.usedOf': '{total} gündən {used} istifadə olunub',
+ 'assistant.card.vacay.uses': '{count} gün aparır',
+ 'assistant.card.vacay.uses.one': '{count} gün aparır',
+ 'assistant.card.vacay.givesBack': '{count} gün geri qaytarır',
+ 'assistant.card.vacay.givesBack.one': '{count} gün geri qaytarır',
+ 'assistant.card.vacay.over': 'Limitdən {count} gün artıq',
+ 'assistant.card.vacay.over.one': 'Limitdən {count} gün artıq',
+ 'assistant.card.vacay.compDays': '{count} kompensasiya günü',
+ 'assistant.card.vacay.compDays.one': '{count} kompensasiya günü',
+ 'assistant.card.vacay.otherDays': 'Bu il daha {count} istirahət günü',
+ 'assistant.card.vacay.otherDays.one': 'Bu il daha {count} istirahət günü',
+ 'assistant.card.vacay.allowance': 'Məzuniyyət limiti',
+ 'assistant.card.vacay.meter': '{used} istifadə olunub, {planned} planlaşdırılıb, {total} gündən {left} qalıb',
+ 'assistant.card.vacay.weekendBlocked': 'Həftəsonu, planınızda bağlıdır',
+ 'assistant.card.vacay.weekend': 'Həftəsonu günü, məzuniyyət sayılır',
+ 'assistant.card.vacay.publicHoliday': 'Rəsmi bayram, məzuniyyət lazım deyil',
+ 'assistant.card.vacay.companyHoliday': 'Şirkət tətili, məzuniyyət lazım deyil',
+ 'assistant.card.vacay.companyHalf': 'Yarım şirkət tətili, yarım gün qalır',
+ 'assistant.card.vacay.notInPlan': 'Məzuniyyət planınızda deyil',
+ 'assistant.card.vacay.companyOff': 'Bu planda şirkət tətilləri söndürülüb',
+ 'assistant.card.vacay.twice': 'İki dəfə yazılıb, bir-birini ləğv edir',
+ 'assistant.card.vacay.trip': 'Bu səfər',
+ 'assistant.card.vacay.tapToSkip': 'Çıxarmaq üçün toxunun',
+ 'assistant.card.vacay.tapToTake': 'Daxil etmək üçün toxunun',
+ 'assistant.card.vacay.open': 'Vacay-da açın',
+ 'assistant.step.vacayEntries': 'Məzuniyyət günləriniz oxunur',
+ 'assistant.step.vacayStats': 'Qalan günləriniz sayılır',
+ 'assistant.step.vacayHolidays': 'Rəsmi bayramlar axtarılır',
+ 'assistant.tool.add_vacay_year': 'Vacay-a il əlavə edin',
+ 'assistant.tool.delete_vacay_year': 'Vacay-dan il silin',
+ 'assistant.tool.update_vacay_plan': 'Vacay ayarlarını dəyişin',
+ 'assistant.tool.set_vacay_color': 'Vacay rənginizi dəyişin',
+ 'assistant.tool.add_holiday_calendar': 'Bayram təqvimi əlavə edin',
+ 'assistant.tool.delete_holiday_calendar': 'Bayram təqvimini silin',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/br/assistantVacay.ts b/shared/src/i18n/br/assistantVacay.ts
index 0a509629a..901d02f43 100644
--- a/shared/src/i18n/br/assistantVacay.ts
+++ b/shared/src/i18n/br/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Tirar um dia de folga',
+ 'assistant.change.verb.removeVacationDay': 'Cancelar um dia de folga',
+ 'assistant.change.verb.changeVacationDay': 'Alterar um dia de folga',
+ 'assistant.change.verb.setCompanyHoliday': 'Definir um feriado da empresa',
+ 'assistant.change.verb.setVacationAllowance': 'Alterar o direito a férias',
+ 'assistant.card.vacay.label': 'Calendário de férias',
+ 'assistant.card.vacay.fullDay': 'Dia inteiro',
+ 'assistant.card.vacay.halfDay': 'Meio dia',
+ 'assistant.card.vacay.compDay': 'Dia de banco de horas',
+ 'assistant.card.vacay.daysLeft': 'Restam {count} dias',
+ 'assistant.card.vacay.daysLeft.one': 'Resta {count} dia',
+ 'assistant.card.vacay.usedDays': '{count} dias usados',
+ 'assistant.card.vacay.usedDays.one': '{count} dia usado',
+ 'assistant.card.vacay.usedOf': '{used} de {total} usados',
+ 'assistant.card.vacay.uses': 'Usa {count} dias',
+ 'assistant.card.vacay.uses.one': 'Usa {count} dia',
+ 'assistant.card.vacay.givesBack': 'Devolve {count} dias',
+ 'assistant.card.vacay.givesBack.one': 'Devolve {count} dia',
+ 'assistant.card.vacay.over': '{count} dias a mais',
+ 'assistant.card.vacay.over.one': '{count} dia a mais',
+ 'assistant.card.vacay.compDays': '{count} dias de banco de horas',
+ 'assistant.card.vacay.compDays.one': '{count} dia de banco de horas',
+ 'assistant.card.vacay.otherDays': 'Mais {count} dias de folga este ano',
+ 'assistant.card.vacay.otherDays.one': 'Mais {count} dia de folga este ano',
+ 'assistant.card.vacay.allowance': 'Direito',
+ 'assistant.card.vacay.meter': '{used} usados, {planned} planejados, restam {left} de {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Fim de semana, bloqueado no seu plano',
+ 'assistant.card.vacay.weekend': 'Dia de fim de semana, conta como férias',
+ 'assistant.card.vacay.publicHoliday': 'Feriado, não precisa de folga',
+ 'assistant.card.vacay.companyHoliday': 'Feriado da empresa, não precisa de folga',
+ 'assistant.card.vacay.companyHalf': 'Meio feriado da empresa, sobra meio dia',
+ 'assistant.card.vacay.notInPlan': 'Fora do seu plano de férias',
+ 'assistant.card.vacay.companyOff': 'Feriados da empresa estão desligados neste plano',
+ 'assistant.card.vacay.twice': 'Listado duas vezes, se anula',
+ 'assistant.card.vacay.trip': 'Esta viagem',
+ 'assistant.card.vacay.tapToSkip': 'Toque para deixar de fora',
+ 'assistant.card.vacay.tapToTake': 'Toque para incluir',
+ 'assistant.card.vacay.open': 'Abrir no Vacay',
+ 'assistant.step.vacayEntries': 'Lendo seus dias de férias',
+ 'assistant.step.vacayStats': 'Contando os dias que restam',
+ 'assistant.step.vacayHolidays': 'Buscando os feriados',
+ 'assistant.tool.add_vacay_year': 'Adicionar um ano ao Vacay',
+ 'assistant.tool.delete_vacay_year': 'Remover um ano do Vacay',
+ 'assistant.tool.update_vacay_plan': 'Alterar as configurações do Vacay',
+ 'assistant.tool.set_vacay_color': 'Mudar sua cor no Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Adicionar um calendário de feriados',
+ 'assistant.tool.delete_holiday_calendar': 'Remover um calendário de feriados',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/ca/assistantVacay.ts b/shared/src/i18n/ca/assistantVacay.ts
index 0a509629a..1a7e0fbe7 100644
--- a/shared/src/i18n/ca/assistantVacay.ts
+++ b/shared/src/i18n/ca/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Agafar un dia de vacances',
+ 'assistant.change.verb.removeVacationDay': 'Anul·lar un dia de vacances',
+ 'assistant.change.verb.changeVacationDay': 'Canviar un dia de vacances',
+ 'assistant.change.verb.setCompanyHoliday': "Fixar un festiu de l'empresa",
+ 'assistant.change.verb.setVacationAllowance': 'Canviar el dret a vacances',
+ 'assistant.card.vacay.label': 'Calendari de vacances',
+ 'assistant.card.vacay.fullDay': 'Dia sencer',
+ 'assistant.card.vacay.halfDay': 'Mig dia',
+ 'assistant.card.vacay.compDay': 'Dia de compensació',
+ 'assistant.card.vacay.daysLeft': 'Queden {count} dies',
+ 'assistant.card.vacay.daysLeft.one': 'Queda {count} dia',
+ 'assistant.card.vacay.usedDays': '{count} dies gastats',
+ 'assistant.card.vacay.usedDays.one': '{count} dia gastat',
+ 'assistant.card.vacay.usedOf': '{used} de {total} gastats',
+ 'assistant.card.vacay.uses': 'Gasta {count} dies',
+ 'assistant.card.vacay.uses.one': 'Gasta {count} dia',
+ 'assistant.card.vacay.givesBack': 'Retorna {count} dies',
+ 'assistant.card.vacay.givesBack.one': 'Retorna {count} dia',
+ 'assistant.card.vacay.over': '{count} dies de més',
+ 'assistant.card.vacay.over.one': '{count} dia de més',
+ 'assistant.card.vacay.compDays': '{count} dies de compensació',
+ 'assistant.card.vacay.compDays.one': '{count} dia de compensació',
+ 'assistant.card.vacay.otherDays': '{count} dies lliures més aquest any',
+ 'assistant.card.vacay.otherDays.one': '{count} dia lliure més aquest any',
+ 'assistant.card.vacay.allowance': 'Dret',
+ 'assistant.card.vacay.meter': '{used} gastats, {planned} previstos, queden {left} de {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Cap de setmana, bloquejat al teu pla',
+ 'assistant.card.vacay.weekend': 'Dia de cap de setmana, compta com a vacances',
+ 'assistant.card.vacay.publicHoliday': 'Festiu, no cal demanar el dia',
+ 'assistant.card.vacay.companyHoliday': "Festiu de l'empresa, no cal demanar el dia",
+ 'assistant.card.vacay.companyHalf': "Mig festiu de l'empresa, queda mig dia",
+ 'assistant.card.vacay.notInPlan': 'No és al teu pla de vacances',
+ 'assistant.card.vacay.companyOff': "Els festius d'empresa estan desactivats en aquest pla",
+ 'assistant.card.vacay.twice': "Apareix dues vegades, s'anul·la",
+ 'assistant.card.vacay.trip': 'Aquest viatge',
+ 'assistant.card.vacay.tapToSkip': 'Toca per deixar-lo fora',
+ 'assistant.card.vacay.tapToTake': 'Toca per incloure-ho',
+ 'assistant.card.vacay.open': 'Obre a Vacay',
+ 'assistant.step.vacayEntries': 'Llegint els teus dies de vacances',
+ 'assistant.step.vacayStats': 'Comptant els dies que et queden',
+ 'assistant.step.vacayHolidays': 'Cercant els festius',
+ 'assistant.tool.add_vacay_year': 'Afegir un any a Vacay',
+ 'assistant.tool.delete_vacay_year': 'Treure un any de Vacay',
+ 'assistant.tool.update_vacay_plan': 'Canviar la configuració de Vacay',
+ 'assistant.tool.set_vacay_color': 'Canviar el teu color a Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Afegir un calendari de festius',
+ 'assistant.tool.delete_holiday_calendar': 'Treure un calendari de festius',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/cs/assistantVacay.ts b/shared/src/i18n/cs/assistantVacay.ts
index 0a509629a..cb44c76da 100644
--- a/shared/src/i18n/cs/assistantVacay.ts
+++ b/shared/src/i18n/cs/assistantVacay.ts
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Vzít si den dovolené',
+ 'assistant.change.verb.removeVacationDay': 'Zrušit den dovolené',
+ 'assistant.change.verb.changeVacationDay': 'Změnit den dovolené',
+ 'assistant.change.verb.setCompanyHoliday': 'Nastavit firemní volno',
+ 'assistant.change.verb.setVacationAllowance': 'Změnit nárok na dovolenou',
+ 'assistant.card.vacay.label': 'Kalendář dovolené',
+ 'assistant.card.vacay.fullDay': 'Celý den',
+ 'assistant.card.vacay.halfDay': 'Půlden',
+ 'assistant.card.vacay.compDay': 'Den náhradního volna',
+ 'assistant.card.vacay.daysLeft': 'Zbývá {count} dní',
+ 'assistant.card.vacay.daysLeft.one': 'Zbývá {count} den',
+ 'assistant.card.vacay.daysLeft.few': 'Zbývají {count} dny',
+ 'assistant.card.vacay.usedDays': 'Vyčerpáno {count} dní',
+ 'assistant.card.vacay.usedDays.one': 'Vyčerpán {count} den',
+ 'assistant.card.vacay.usedDays.few': 'Vyčerpány {count} dny',
+ 'assistant.card.vacay.usedOf': 'Vyčerpáno {used} z {total}',
+ 'assistant.card.vacay.uses': 'Spotřebuje {count} dní',
+ 'assistant.card.vacay.uses.one': 'Spotřebuje {count} den',
+ 'assistant.card.vacay.uses.few': 'Spotřebuje {count} dny',
+ 'assistant.card.vacay.givesBack': 'Vrátí {count} dní',
+ 'assistant.card.vacay.givesBack.one': 'Vrátí {count} den',
+ 'assistant.card.vacay.givesBack.few': 'Vrátí {count} dny',
+ 'assistant.card.vacay.over': 'O {count} dní navíc',
+ 'assistant.card.vacay.over.one': 'O {count} den navíc',
+ 'assistant.card.vacay.over.few': 'O {count} dny navíc',
+ 'assistant.card.vacay.compDays': '{count} dní náhradního volna',
+ 'assistant.card.vacay.compDays.one': '{count} den náhradního volna',
+ 'assistant.card.vacay.compDays.few': '{count} dny náhradního volna',
+ 'assistant.card.vacay.otherDays': 'Letos ještě {count} dní volna',
+ 'assistant.card.vacay.otherDays.one': 'Letos ještě {count} den volna',
+ 'assistant.card.vacay.otherDays.few': 'Letos ještě {count} dny volna',
+ 'assistant.card.vacay.allowance': 'Nárok',
+ 'assistant.card.vacay.meter': 'Vyčerpáno {used}, naplánováno {planned}, zbývá {left} z {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Víkend, ve tvém plánu zablokovaný',
+ 'assistant.card.vacay.weekend': 'Víkendový den, počítá se jako dovolená',
+ 'assistant.card.vacay.publicHoliday': 'Státní svátek, dovolená není potřeba',
+ 'assistant.card.vacay.companyHoliday': 'Firemní volno, dovolená není potřeba',
+ 'assistant.card.vacay.companyHalf': 'Půlden firemního volna, zbývá půl dne',
+ 'assistant.card.vacay.notInPlan': 'Není ve tvém plánu dovolené',
+ 'assistant.card.vacay.companyOff': 'Firemní volno je v tomto plánu vypnuté',
+ 'assistant.card.vacay.twice': 'Uvedeno dvakrát, ruší se',
+ 'assistant.card.vacay.trip': 'Tato cesta',
+ 'assistant.card.vacay.tapToSkip': 'Klepnutím vynecháte',
+ 'assistant.card.vacay.tapToTake': 'Klepnutím zahrnete',
+ 'assistant.card.vacay.open': 'Otevřít ve Vacay',
+ 'assistant.step.vacayEntries': 'Načítám tvé dny dovolené',
+ 'assistant.step.vacayStats': 'Počítám zbývající dny',
+ 'assistant.step.vacayHolidays': 'Hledám státní svátky',
+ 'assistant.tool.add_vacay_year': 'Přidat rok do Vacay',
+ 'assistant.tool.delete_vacay_year': 'Odebrat rok z Vacay',
+ 'assistant.tool.update_vacay_plan': 'Změnit nastavení Vacay',
+ 'assistant.tool.set_vacay_color': 'Změnit svou barvu ve Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Přidat kalendář svátků',
+ 'assistant.tool.delete_holiday_calendar': 'Odebrat kalendář svátků',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/de/assistantVacay.ts b/shared/src/i18n/de/assistantVacay.ts
index 0a509629a..cde2ce5cd 100644
--- a/shared/src/i18n/de/assistantVacay.ts
+++ b/shared/src/i18n/de/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Einen Urlaubstag nehmen',
+ 'assistant.change.verb.removeVacationDay': 'Einen Urlaubstag streichen',
+ 'assistant.change.verb.changeVacationDay': 'Einen Urlaubstag ändern',
+ 'assistant.change.verb.setCompanyHoliday': 'Betriebsferien eintragen',
+ 'assistant.change.verb.setVacationAllowance': 'Urlaubsanspruch ändern',
+ 'assistant.card.vacay.label': 'Urlaubskalender',
+ 'assistant.card.vacay.fullDay': 'Ganzer Tag',
+ 'assistant.card.vacay.halfDay': 'Halber Tag',
+ 'assistant.card.vacay.compDay': 'Ausgleichstag',
+ 'assistant.card.vacay.daysLeft': '{count} Tage übrig',
+ 'assistant.card.vacay.daysLeft.one': '{count} Tag übrig',
+ 'assistant.card.vacay.usedDays': '{count} Tage genommen',
+ 'assistant.card.vacay.usedDays.one': '{count} Tag genommen',
+ 'assistant.card.vacay.usedOf': '{used} von {total} genommen',
+ 'assistant.card.vacay.uses': 'Braucht {count} Tage',
+ 'assistant.card.vacay.uses.one': 'Braucht {count} Tag',
+ 'assistant.card.vacay.givesBack': 'Gibt {count} Tage zurück',
+ 'assistant.card.vacay.givesBack.one': 'Gibt {count} Tag zurück',
+ 'assistant.card.vacay.over': '{count} Tage zu viel',
+ 'assistant.card.vacay.over.one': '{count} Tag zu viel',
+ 'assistant.card.vacay.compDays': '{count} Ausgleichstage',
+ 'assistant.card.vacay.compDays.one': '{count} Ausgleichstag',
+ 'assistant.card.vacay.otherDays': '{count} weitere freie Tage in diesem Jahr',
+ 'assistant.card.vacay.otherDays.one': '{count} weiterer freier Tag in diesem Jahr',
+ 'assistant.card.vacay.allowance': 'Urlaubsanspruch',
+ 'assistant.card.vacay.meter': '{used} genommen, {planned} geplant, {left} übrig von {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Wochenende, in deinem Plan gesperrt',
+ 'assistant.card.vacay.weekend': 'Wochenendtag, zählt als Urlaub',
+ 'assistant.card.vacay.publicHoliday': 'Feiertag, kein Urlaub nötig',
+ 'assistant.card.vacay.companyHoliday': 'Betriebsferien, kein Urlaub nötig',
+ 'assistant.card.vacay.companyHalf': 'Halber Betriebsferientag, ein halber Tag bleibt',
+ 'assistant.card.vacay.notInPlan': 'Nicht in deinem Urlaubsplan',
+ 'assistant.card.vacay.companyOff': 'Betriebsferien sind in diesem Plan ausgeschaltet',
+ 'assistant.card.vacay.twice': 'Doppelt aufgeführt, hebt sich auf',
+ 'assistant.card.vacay.trip': 'Diese Reise',
+ 'assistant.card.vacay.tapToSkip': 'Tippen zum Weglassen',
+ 'assistant.card.vacay.tapToTake': 'Tippen zum Übernehmen',
+ 'assistant.card.vacay.open': 'In Vacay öffnen',
+ 'assistant.step.vacayEntries': 'Liest deine Urlaubstage',
+ 'assistant.step.vacayStats': 'Zählt deine restlichen Tage',
+ 'assistant.step.vacayHolidays': 'Sucht die Feiertage',
+ 'assistant.tool.add_vacay_year': 'Jahr zu Vacay hinzufügen',
+ 'assistant.tool.delete_vacay_year': 'Jahr aus Vacay entfernen',
+ 'assistant.tool.update_vacay_plan': 'Vacay-Einstellungen ändern',
+ 'assistant.tool.set_vacay_color': 'Deine Vacay-Farbe ändern',
+ 'assistant.tool.add_holiday_calendar': 'Feiertagskalender hinzufügen',
+ 'assistant.tool.delete_holiday_calendar': 'Feiertagskalender entfernen',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/en/assistantVacay.ts b/shared/src/i18n/en/assistantVacay.ts
index 0a509629a..4af63c7e1 100644
--- a/shared/src/i18n/en/assistantVacay.ts
+++ b/shared/src/i18n/en/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Take a day off',
+ 'assistant.change.verb.removeVacationDay': 'Cancel a day off',
+ 'assistant.change.verb.changeVacationDay': 'Change a day off',
+ 'assistant.change.verb.setCompanyHoliday': 'Set a company holiday',
+ 'assistant.change.verb.setVacationAllowance': 'Change the vacation allowance',
+ 'assistant.card.vacay.label': 'Vacation calendar',
+ 'assistant.card.vacay.fullDay': 'Full day',
+ 'assistant.card.vacay.halfDay': 'Half day',
+ 'assistant.card.vacay.compDay': 'Comp day',
+ 'assistant.card.vacay.daysLeft': '{count} days left',
+ 'assistant.card.vacay.daysLeft.one': '{count} day left',
+ 'assistant.card.vacay.usedDays': '{count} days used',
+ 'assistant.card.vacay.usedDays.one': '{count} day used',
+ 'assistant.card.vacay.usedOf': '{used} of {total} used',
+ 'assistant.card.vacay.uses': 'Uses {count} days',
+ 'assistant.card.vacay.uses.one': 'Uses {count} day',
+ 'assistant.card.vacay.givesBack': 'Gives back {count} days',
+ 'assistant.card.vacay.givesBack.one': 'Gives back {count} day',
+ 'assistant.card.vacay.over': 'Over by {count} days',
+ 'assistant.card.vacay.over.one': 'Over by {count} day',
+ 'assistant.card.vacay.compDays': '{count} comp days',
+ 'assistant.card.vacay.compDays.one': '{count} comp day',
+ 'assistant.card.vacay.otherDays': '{count} more days off this year',
+ 'assistant.card.vacay.otherDays.one': '{count} more day off this year',
+ 'assistant.card.vacay.allowance': 'Allowance',
+ 'assistant.card.vacay.meter': '{used} used, {planned} planned, {left} left of {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Weekend, blocked in your plan',
+ 'assistant.card.vacay.weekend': 'Weekend day, counts as leave',
+ 'assistant.card.vacay.publicHoliday': 'Public holiday, no leave needed',
+ 'assistant.card.vacay.companyHoliday': 'Company holiday, no leave needed',
+ 'assistant.card.vacay.companyHalf': 'Half company holiday, half a day is left',
+ 'assistant.card.vacay.notInPlan': 'Not in your vacation plan',
+ 'assistant.card.vacay.companyOff': 'Company holidays are turned off in this plan',
+ 'assistant.card.vacay.twice': 'Listed twice, cancels out',
+ 'assistant.card.vacay.trip': 'This trip',
+ 'assistant.card.vacay.tapToSkip': 'Tap to leave out',
+ 'assistant.card.vacay.tapToTake': 'Tap to take in',
+ 'assistant.card.vacay.open': 'Open in Vacay',
+ 'assistant.step.vacayEntries': 'Reading your vacation days',
+ 'assistant.step.vacayStats': 'Counting the days you have left',
+ 'assistant.step.vacayHolidays': 'Looking up public holidays',
+ 'assistant.tool.add_vacay_year': 'Add a year to Vacay',
+ 'assistant.tool.delete_vacay_year': 'Remove a year from Vacay',
+ 'assistant.tool.update_vacay_plan': 'Change the Vacay settings',
+ 'assistant.tool.set_vacay_color': 'Change your Vacay color',
+ 'assistant.tool.add_holiday_calendar': 'Add a holiday calendar',
+ 'assistant.tool.delete_holiday_calendar': 'Remove a holiday calendar',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/es/assistantVacay.ts b/shared/src/i18n/es/assistantVacay.ts
index 0a509629a..4e1a48c6a 100644
--- a/shared/src/i18n/es/assistantVacay.ts
+++ b/shared/src/i18n/es/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Tomar un día libre',
+ 'assistant.change.verb.removeVacationDay': 'Cancelar un día libre',
+ 'assistant.change.verb.changeVacationDay': 'Cambiar un día libre',
+ 'assistant.change.verb.setCompanyHoliday': 'Fijar un festivo de empresa',
+ 'assistant.change.verb.setVacationAllowance': 'Cambiar los días de vacaciones',
+ 'assistant.card.vacay.label': 'Calendario de vacaciones',
+ 'assistant.card.vacay.fullDay': 'Día completo',
+ 'assistant.card.vacay.halfDay': 'Medio día',
+ 'assistant.card.vacay.compDay': 'Día de compensación',
+ 'assistant.card.vacay.daysLeft': 'Quedan {count} días',
+ 'assistant.card.vacay.daysLeft.one': 'Queda {count} día',
+ 'assistant.card.vacay.usedDays': '{count} días usados',
+ 'assistant.card.vacay.usedDays.one': '{count} día usado',
+ 'assistant.card.vacay.usedOf': '{used} de {total} usados',
+ 'assistant.card.vacay.uses': 'Usa {count} días',
+ 'assistant.card.vacay.uses.one': 'Usa {count} día',
+ 'assistant.card.vacay.givesBack': 'Devuelve {count} días',
+ 'assistant.card.vacay.givesBack.one': 'Devuelve {count} día',
+ 'assistant.card.vacay.over': '{count} días de más',
+ 'assistant.card.vacay.over.one': '{count} día de más',
+ 'assistant.card.vacay.compDays': '{count} días de compensación',
+ 'assistant.card.vacay.compDays.one': '{count} día de compensación',
+ 'assistant.card.vacay.otherDays': '{count} días libres más este año',
+ 'assistant.card.vacay.otherDays.one': '{count} día libre más este año',
+ 'assistant.card.vacay.allowance': 'Derecho',
+ 'assistant.card.vacay.meter': '{used} usados, {planned} previstos, quedan {left} de {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Fin de semana, bloqueado en tu plan',
+ 'assistant.card.vacay.weekend': 'Día de fin de semana, cuenta como vacaciones',
+ 'assistant.card.vacay.publicHoliday': 'Festivo, no hace falta pedir el día',
+ 'assistant.card.vacay.companyHoliday': 'Festivo de empresa, no hace falta pedir el día',
+ 'assistant.card.vacay.companyHalf': 'Medio festivo de empresa, queda medio día',
+ 'assistant.card.vacay.notInPlan': 'No está en tu plan de vacaciones',
+ 'assistant.card.vacay.companyOff': 'Los festivos de empresa están desactivados en este plan',
+ 'assistant.card.vacay.twice': 'Aparece dos veces, se anula',
+ 'assistant.card.vacay.trip': 'Este viaje',
+ 'assistant.card.vacay.tapToSkip': 'Toca para dejarlo fuera',
+ 'assistant.card.vacay.tapToTake': 'Toca para incluirlo',
+ 'assistant.card.vacay.open': 'Abrir en Vacay',
+ 'assistant.step.vacayEntries': 'Leyendo tus días de vacaciones',
+ 'assistant.step.vacayStats': 'Contando los días que te quedan',
+ 'assistant.step.vacayHolidays': 'Buscando los festivos',
+ 'assistant.tool.add_vacay_year': 'Añadir un año a Vacay',
+ 'assistant.tool.delete_vacay_year': 'Quitar un año de Vacay',
+ 'assistant.tool.update_vacay_plan': 'Cambiar los ajustes de Vacay',
+ 'assistant.tool.set_vacay_color': 'Cambiar tu color en Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Añadir un calendario de festivos',
+ 'assistant.tool.delete_holiday_calendar': 'Quitar un calendario de festivos',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/et/assistantVacay.ts b/shared/src/i18n/et/assistantVacay.ts
index 0a509629a..cf72d4058 100644
--- a/shared/src/i18n/et/assistantVacay.ts
+++ b/shared/src/i18n/et/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Võta vaba päev',
+ 'assistant.change.verb.removeVacationDay': 'Tühista vaba päev',
+ 'assistant.change.verb.changeVacationDay': 'Muuda vaba päeva',
+ 'assistant.change.verb.setCompanyHoliday': 'Määra ettevõtte puhkepäev',
+ 'assistant.change.verb.setVacationAllowance': 'Muuda puhkusepäevade õigust',
+ 'assistant.card.vacay.label': 'Puhkusekalender',
+ 'assistant.card.vacay.fullDay': 'Terve päev',
+ 'assistant.card.vacay.halfDay': 'Pool päeva',
+ 'assistant.card.vacay.compDay': 'Tasaarvestuspäev',
+ 'assistant.card.vacay.daysLeft': '{count} päeva jäänud',
+ 'assistant.card.vacay.daysLeft.one': '{count} päev jäänud',
+ 'assistant.card.vacay.usedDays': '{count} päeva kasutatud',
+ 'assistant.card.vacay.usedDays.one': '{count} päev kasutatud',
+ 'assistant.card.vacay.usedOf': '{total} päevast {used} kasutatud',
+ 'assistant.card.vacay.uses': 'Kasutab {count} päeva',
+ 'assistant.card.vacay.uses.one': 'Kasutab {count} päeva',
+ 'assistant.card.vacay.givesBack': 'Annab tagasi {count} päeva',
+ 'assistant.card.vacay.givesBack.one': 'Annab tagasi {count} päeva',
+ 'assistant.card.vacay.over': '{count} päeva üle',
+ 'assistant.card.vacay.over.one': '{count} päev üle',
+ 'assistant.card.vacay.compDays': '{count} tasaarvestuspäeva',
+ 'assistant.card.vacay.compDays.one': '{count} tasaarvestuspäev',
+ 'assistant.card.vacay.otherDays': 'Sel aastal veel {count} vaba päeva',
+ 'assistant.card.vacay.otherDays.one': 'Sel aastal veel {count} vaba päev',
+ 'assistant.card.vacay.allowance': 'Puhkusepäevade õigus',
+ 'assistant.card.vacay.meter': '{used} kasutatud, {planned} plaanis, {total} päevast {left} jäänud',
+ 'assistant.card.vacay.weekendBlocked': 'Nädalavahetus, sinu plaanis blokeeritud',
+ 'assistant.card.vacay.weekend': 'Nädalavahetuse päev, loetakse puhkuseks',
+ 'assistant.card.vacay.publicHoliday': 'Riigipüha, puhkust pole vaja',
+ 'assistant.card.vacay.companyHoliday': 'Ettevõtte puhkepäev, puhkust pole vaja',
+ 'assistant.card.vacay.companyHalf': 'Pool ettevõtte puhkepäeva, pool päeva jääb',
+ 'assistant.card.vacay.notInPlan': 'Pole sinu puhkuseplaanis',
+ 'assistant.card.vacay.companyOff': 'Ettevõtte puhkepäevad on selles plaanis välja lülitatud',
+ 'assistant.card.vacay.twice': 'Kaks korda kirjas, tühistavad teineteist',
+ 'assistant.card.vacay.trip': 'See reis',
+ 'assistant.card.vacay.tapToSkip': 'Puuduta väljajätmiseks',
+ 'assistant.card.vacay.tapToTake': 'Puuduta lisamiseks',
+ 'assistant.card.vacay.open': 'Ava Vacays',
+ 'assistant.step.vacayEntries': 'Loen sinu puhkusepäevi',
+ 'assistant.step.vacayStats': 'Loen kokku järelejäänud päevad',
+ 'assistant.step.vacayHolidays': 'Otsin riigipühi',
+ 'assistant.tool.add_vacay_year': 'Lisa Vacaysse aasta',
+ 'assistant.tool.delete_vacay_year': 'Eemalda Vacayst aasta',
+ 'assistant.tool.update_vacay_plan': 'Muuda Vacay seadeid',
+ 'assistant.tool.set_vacay_color': 'Muuda oma Vacay värvi',
+ 'assistant.tool.add_holiday_calendar': 'Lisa pühade kalender',
+ 'assistant.tool.delete_holiday_calendar': 'Eemalda pühade kalender',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/fr/assistantVacay.ts b/shared/src/i18n/fr/assistantVacay.ts
index 0a509629a..43cd7795f 100644
--- a/shared/src/i18n/fr/assistantVacay.ts
+++ b/shared/src/i18n/fr/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Poser un jour de congé',
+ 'assistant.change.verb.removeVacationDay': 'Annuler un jour de congé',
+ 'assistant.change.verb.changeVacationDay': 'Modifier un jour de congé',
+ 'assistant.change.verb.setCompanyHoliday': "Définir un jour férié d'entreprise",
+ 'assistant.change.verb.setVacationAllowance': 'Modifier les droits aux congés',
+ 'assistant.card.vacay.label': 'Calendrier des congés',
+ 'assistant.card.vacay.fullDay': 'Journée entière',
+ 'assistant.card.vacay.halfDay': 'Demi-journée',
+ 'assistant.card.vacay.compDay': 'Jour de récupération',
+ 'assistant.card.vacay.daysLeft': '{count} jours restants',
+ 'assistant.card.vacay.daysLeft.one': '{count} jour restant',
+ 'assistant.card.vacay.usedDays': '{count} jours pris',
+ 'assistant.card.vacay.usedDays.one': '{count} jour pris',
+ 'assistant.card.vacay.usedOf': '{used} sur {total} pris',
+ 'assistant.card.vacay.uses': 'Utilise {count} jours',
+ 'assistant.card.vacay.uses.one': 'Utilise {count} jour',
+ 'assistant.card.vacay.givesBack': 'Rend {count} jours',
+ 'assistant.card.vacay.givesBack.one': 'Rend {count} jour',
+ 'assistant.card.vacay.over': '{count} jours de trop',
+ 'assistant.card.vacay.over.one': '{count} jour de trop',
+ 'assistant.card.vacay.compDays': '{count} jours de récupération',
+ 'assistant.card.vacay.compDays.one': '{count} jour de récupération',
+ 'assistant.card.vacay.otherDays': '{count} autres jours de congé cette année',
+ 'assistant.card.vacay.otherDays.one': '{count} autre jour de congé cette année',
+ 'assistant.card.vacay.allowance': 'Droits',
+ 'assistant.card.vacay.meter': '{used} pris, {planned} prévus, {left} restants sur {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Week-end, bloqué dans votre plan',
+ 'assistant.card.vacay.weekend': 'Jour de week-end, compté comme congé',
+ 'assistant.card.vacay.publicHoliday': 'Jour férié, pas besoin de congé',
+ 'assistant.card.vacay.companyHoliday': "Jour férié d'entreprise, pas besoin de congé",
+ 'assistant.card.vacay.companyHalf': "Demi-journée fériée d'entreprise, il reste une demi-journée",
+ 'assistant.card.vacay.notInPlan': 'Pas dans votre plan de congés',
+ 'assistant.card.vacay.companyOff': "Les jours fériés d'entreprise sont désactivés dans ce plan",
+ 'assistant.card.vacay.twice': "Listé deux fois, s'annule",
+ 'assistant.card.vacay.trip': 'Ce voyage',
+ 'assistant.card.vacay.tapToSkip': 'Touchez pour exclure',
+ 'assistant.card.vacay.tapToTake': 'Touchez pour inclure',
+ 'assistant.card.vacay.open': 'Ouvrir dans Vacay',
+ 'assistant.step.vacayEntries': 'Lecture de vos jours de congé',
+ 'assistant.step.vacayStats': 'Calcul des jours restants',
+ 'assistant.step.vacayHolidays': 'Recherche des jours fériés',
+ 'assistant.tool.add_vacay_year': 'Ajouter une année à Vacay',
+ 'assistant.tool.delete_vacay_year': 'Retirer une année de Vacay',
+ 'assistant.tool.update_vacay_plan': 'Modifier les réglages de Vacay',
+ 'assistant.tool.set_vacay_color': 'Changer votre couleur dans Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Ajouter un calendrier de jours fériés',
+ 'assistant.tool.delete_holiday_calendar': 'Retirer un calendrier de jours fériés',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/gr/assistantVacay.ts b/shared/src/i18n/gr/assistantVacay.ts
index 0a509629a..e24fb6f72 100644
--- a/shared/src/i18n/gr/assistantVacay.ts
+++ b/shared/src/i18n/gr/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Λήψη ημέρας άδειας',
+ 'assistant.change.verb.removeVacationDay': 'Ακύρωση ημέρας άδειας',
+ 'assistant.change.verb.changeVacationDay': 'Αλλαγή ημέρας άδειας',
+ 'assistant.change.verb.setCompanyHoliday': 'Ορισμός εταιρικής αργίας',
+ 'assistant.change.verb.setVacationAllowance': 'Αλλαγή δικαιώματος αδείας',
+ 'assistant.card.vacay.label': 'Ημερολόγιο αδειών',
+ 'assistant.card.vacay.fullDay': 'Ολόκληρη μέρα',
+ 'assistant.card.vacay.halfDay': 'Μισή μέρα',
+ 'assistant.card.vacay.compDay': 'Ρεπό',
+ 'assistant.card.vacay.daysLeft': 'Απομένουν {count} μέρες',
+ 'assistant.card.vacay.daysLeft.one': 'Απομένει {count} μέρα',
+ 'assistant.card.vacay.usedDays': '{count} μέρες χρησιμοποιήθηκαν',
+ 'assistant.card.vacay.usedDays.one': '{count} μέρα χρησιμοποιήθηκε',
+ 'assistant.card.vacay.usedOf': '{used} από {total} χρησιμοποιήθηκαν',
+ 'assistant.card.vacay.uses': 'Χρησιμοποιεί {count} μέρες',
+ 'assistant.card.vacay.uses.one': 'Χρησιμοποιεί {count} μέρα',
+ 'assistant.card.vacay.givesBack': 'Επιστρέφει {count} μέρες',
+ 'assistant.card.vacay.givesBack.one': 'Επιστρέφει {count} μέρα',
+ 'assistant.card.vacay.over': '{count} μέρες παραπάνω',
+ 'assistant.card.vacay.over.one': '{count} μέρα παραπάνω',
+ 'assistant.card.vacay.compDays': '{count} μέρες ρεπό',
+ 'assistant.card.vacay.compDays.one': '{count} μέρα ρεπό',
+ 'assistant.card.vacay.otherDays': 'Άλλες {count} μέρες άδειας φέτος',
+ 'assistant.card.vacay.otherDays.one': 'Άλλη {count} μέρα άδειας φέτος',
+ 'assistant.card.vacay.allowance': 'Δικαίωμα',
+ 'assistant.card.vacay.meter': '{used} χρησιμοποιήθηκαν, {planned} σχεδιάζονται, απομένουν {left} από {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Σαββατοκύριακο, μπλοκαρισμένο στο πλάνο σου',
+ 'assistant.card.vacay.weekend': 'Μέρα Σαββατοκύριακου, μετράει ως άδεια',
+ 'assistant.card.vacay.publicHoliday': 'Δημόσια αργία, δεν χρειάζεται άδεια',
+ 'assistant.card.vacay.companyHoliday': 'Εταιρική αργία, δεν χρειάζεται άδεια',
+ 'assistant.card.vacay.companyHalf': 'Μισή εταιρική αργία, μένει μισή μέρα',
+ 'assistant.card.vacay.notInPlan': 'Δεν είναι στο πλάνο αδειών σου',
+ 'assistant.card.vacay.companyOff': 'Οι εταιρικές αργίες είναι απενεργοποιημένες σε αυτό το πλάνο',
+ 'assistant.card.vacay.twice': 'Δύο φορές στη λίστα, αλληλοαναιρούνται',
+ 'assistant.card.vacay.trip': 'Αυτό το ταξίδι',
+ 'assistant.card.vacay.tapToSkip': 'Πάτησε για εξαίρεση',
+ 'assistant.card.vacay.tapToTake': 'Πάτησε για συμπερίληψη',
+ 'assistant.card.vacay.open': 'Άνοιγμα στο Vacay',
+ 'assistant.step.vacayEntries': 'Διαβάζω τις μέρες άδειάς σου',
+ 'assistant.step.vacayStats': 'Μετράω τις μέρες που απομένουν',
+ 'assistant.step.vacayHolidays': 'Αναζητώ τις αργίες',
+ 'assistant.tool.add_vacay_year': 'Προσθήκη έτους στο Vacay',
+ 'assistant.tool.delete_vacay_year': 'Αφαίρεση έτους από το Vacay',
+ 'assistant.tool.update_vacay_plan': 'Αλλαγή ρυθμίσεων Vacay',
+ 'assistant.tool.set_vacay_color': 'Αλλαγή του χρώματός σου στο Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Προσθήκη ημερολογίου αργιών',
+ 'assistant.tool.delete_holiday_calendar': 'Αφαίρεση ημερολογίου αργιών',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/hu/assistantVacay.ts b/shared/src/i18n/hu/assistantVacay.ts
index 0a509629a..1566a5f2c 100644
--- a/shared/src/i18n/hu/assistantVacay.ts
+++ b/shared/src/i18n/hu/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Szabadnap kivétele',
+ 'assistant.change.verb.removeVacationDay': 'Szabadnap törlése',
+ 'assistant.change.verb.changeVacationDay': 'Szabadnap módosítása',
+ 'assistant.change.verb.setCompanyHoliday': 'Céges szabadnap beállítása',
+ 'assistant.change.verb.setVacationAllowance': 'Szabadságkeret módosítása',
+ 'assistant.card.vacay.label': 'Szabadságnaptár',
+ 'assistant.card.vacay.fullDay': 'Egész nap',
+ 'assistant.card.vacay.halfDay': 'Fél nap',
+ 'assistant.card.vacay.compDay': 'Csúsztatott nap',
+ 'assistant.card.vacay.daysLeft': '{count} nap maradt',
+ 'assistant.card.vacay.daysLeft.one': '{count} nap maradt',
+ 'assistant.card.vacay.usedDays': '{count} nap felhasználva',
+ 'assistant.card.vacay.usedDays.one': '{count} nap felhasználva',
+ 'assistant.card.vacay.usedOf': '{total} napból {used} felhasználva',
+ 'assistant.card.vacay.uses': '{count} napot használ fel',
+ 'assistant.card.vacay.uses.one': '{count} napot használ fel',
+ 'assistant.card.vacay.givesBack': '{count} napot ad vissza',
+ 'assistant.card.vacay.givesBack.one': '{count} napot ad vissza',
+ 'assistant.card.vacay.over': '{count} nappal több a keretnél',
+ 'assistant.card.vacay.over.one': '{count} nappal több a keretnél',
+ 'assistant.card.vacay.compDays': '{count} csúsztatott nap',
+ 'assistant.card.vacay.compDays.one': '{count} csúsztatott nap',
+ 'assistant.card.vacay.otherDays': 'Idén még {count} szabadnap',
+ 'assistant.card.vacay.otherDays.one': 'Idén még {count} szabadnap',
+ 'assistant.card.vacay.allowance': 'Szabadságkeret',
+ 'assistant.card.vacay.meter': '{used} felhasználva, {planned} tervezve, {total} napból {left} maradt',
+ 'assistant.card.vacay.weekendBlocked': 'Hétvége, a tervedben letiltva',
+ 'assistant.card.vacay.weekend': 'Hétvégi nap, szabadságnak számít',
+ 'assistant.card.vacay.publicHoliday': 'Ünnepnap, nem kell szabadság',
+ 'assistant.card.vacay.companyHoliday': 'Céges szabadnap, nem kell szabadság',
+ 'assistant.card.vacay.companyHalf': 'Fél céges szabadnap, fél nap marad',
+ 'assistant.card.vacay.notInPlan': 'Nincs a szabadságtervedben',
+ 'assistant.card.vacay.companyOff': 'A céges szabadnapok ki vannak kapcsolva ebben a tervben',
+ 'assistant.card.vacay.twice': 'Kétszer szerepel, kioltják egymást',
+ 'assistant.card.vacay.trip': 'Ez az utazás',
+ 'assistant.card.vacay.tapToSkip': 'Koppints a kihagyáshoz',
+ 'assistant.card.vacay.tapToTake': 'Koppints a felvételhez',
+ 'assistant.card.vacay.open': 'Megnyitás a Vacayben',
+ 'assistant.step.vacayEntries': 'Szabadnapjaid beolvasása',
+ 'assistant.step.vacayStats': 'Megmaradt napjaid számolása',
+ 'assistant.step.vacayHolidays': 'Ünnepnapok keresése',
+ 'assistant.tool.add_vacay_year': 'Év hozzáadása a Vacayhez',
+ 'assistant.tool.delete_vacay_year': 'Év eltávolítása a Vacayből',
+ 'assistant.tool.update_vacay_plan': 'Vacay-beállítások módosítása',
+ 'assistant.tool.set_vacay_color': 'Vacay-színed módosítása',
+ 'assistant.tool.add_holiday_calendar': 'Ünnepnaptár hozzáadása',
+ 'assistant.tool.delete_holiday_calendar': 'Ünnepnaptár eltávolítása',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/id/assistantVacay.ts b/shared/src/i18n/id/assistantVacay.ts
index 0a509629a..e1b6e4894 100644
--- a/shared/src/i18n/id/assistantVacay.ts
+++ b/shared/src/i18n/id/assistantVacay.ts
@@ -1,4 +1,45 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Ambil hari cuti',
+ 'assistant.change.verb.removeVacationDay': 'Batalkan hari cuti',
+ 'assistant.change.verb.changeVacationDay': 'Ubah hari cuti',
+ 'assistant.change.verb.setCompanyHoliday': 'Tetapkan libur perusahaan',
+ 'assistant.change.verb.setVacationAllowance': 'Ubah jatah cuti',
+ 'assistant.card.vacay.label': 'Kalender cuti',
+ 'assistant.card.vacay.fullDay': 'Sehari penuh',
+ 'assistant.card.vacay.halfDay': 'Setengah hari',
+ 'assistant.card.vacay.compDay': 'Hari pengganti',
+ 'assistant.card.vacay.daysLeft': 'Sisa {count} hari',
+ 'assistant.card.vacay.usedDays': '{count} hari terpakai',
+ 'assistant.card.vacay.usedOf': '{used} dari {total} terpakai',
+ 'assistant.card.vacay.uses': 'Memakai {count} hari',
+ 'assistant.card.vacay.givesBack': 'Mengembalikan {count} hari',
+ 'assistant.card.vacay.over': 'Lebih {count} hari dari jatah',
+ 'assistant.card.vacay.compDays': '{count} hari pengganti',
+ 'assistant.card.vacay.otherDays': '{count} hari libur lagi tahun ini',
+ 'assistant.card.vacay.allowance': 'Jatah cuti',
+ 'assistant.card.vacay.meter': '{used} terpakai, {planned} direncanakan, sisa {left} dari {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Akhir pekan, diblokir di rencanamu',
+ 'assistant.card.vacay.weekend': 'Hari akhir pekan, dihitung sebagai cuti',
+ 'assistant.card.vacay.publicHoliday': 'Hari libur nasional, tidak perlu cuti',
+ 'assistant.card.vacay.companyHoliday': 'Libur perusahaan, tidak perlu cuti',
+ 'assistant.card.vacay.companyHalf': 'Setengah libur perusahaan, sisa setengah hari',
+ 'assistant.card.vacay.notInPlan': 'Tidak ada di rencana cutimu',
+ 'assistant.card.vacay.companyOff': 'Libur perusahaan dimatikan di rencana ini',
+ 'assistant.card.vacay.twice': 'Tercantum dua kali, saling membatalkan',
+ 'assistant.card.vacay.trip': 'Perjalanan ini',
+ 'assistant.card.vacay.tapToSkip': 'Ketuk untuk mengecualikan',
+ 'assistant.card.vacay.tapToTake': 'Ketuk untuk menyertakan',
+ 'assistant.card.vacay.open': 'Buka di Vacay',
+ 'assistant.step.vacayEntries': 'Membaca hari cutimu',
+ 'assistant.step.vacayStats': 'Menghitung sisa harimu',
+ 'assistant.step.vacayHolidays': 'Mencari hari libur nasional',
+ 'assistant.tool.add_vacay_year': 'Tambah tahun ke Vacay',
+ 'assistant.tool.delete_vacay_year': 'Hapus tahun dari Vacay',
+ 'assistant.tool.update_vacay_plan': 'Ubah pengaturan Vacay',
+ 'assistant.tool.set_vacay_color': 'Ubah warnamu di Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Tambah kalender libur',
+ 'assistant.tool.delete_holiday_calendar': 'Hapus kalender libur',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/it/assistantVacay.ts b/shared/src/i18n/it/assistantVacay.ts
index 0a509629a..4a1371c72 100644
--- a/shared/src/i18n/it/assistantVacay.ts
+++ b/shared/src/i18n/it/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Prendere un giorno di ferie',
+ 'assistant.change.verb.removeVacationDay': 'Annullare un giorno di ferie',
+ 'assistant.change.verb.changeVacationDay': 'Modificare un giorno di ferie',
+ 'assistant.change.verb.setCompanyHoliday': 'Impostare le ferie aziendali',
+ 'assistant.change.verb.setVacationAllowance': 'Modificare la disponibilità di ferie',
+ 'assistant.card.vacay.label': 'Calendario ferie',
+ 'assistant.card.vacay.fullDay': 'Giornata intera',
+ 'assistant.card.vacay.halfDay': 'Mezza giornata',
+ 'assistant.card.vacay.compDay': 'Giorno di recupero',
+ 'assistant.card.vacay.daysLeft': '{count} giorni rimasti',
+ 'assistant.card.vacay.daysLeft.one': '{count} giorno rimasto',
+ 'assistant.card.vacay.usedDays': '{count} giorni usati',
+ 'assistant.card.vacay.usedDays.one': '{count} giorno usato',
+ 'assistant.card.vacay.usedOf': '{used} di {total} usati',
+ 'assistant.card.vacay.uses': 'Usa {count} giorni',
+ 'assistant.card.vacay.uses.one': 'Usa {count} giorno',
+ 'assistant.card.vacay.givesBack': 'Restituisce {count} giorni',
+ 'assistant.card.vacay.givesBack.one': 'Restituisce {count} giorno',
+ 'assistant.card.vacay.over': '{count} giorni in più',
+ 'assistant.card.vacay.over.one': '{count} giorno in più',
+ 'assistant.card.vacay.compDays': '{count} giorni di recupero',
+ 'assistant.card.vacay.compDays.one': '{count} giorno di recupero',
+ 'assistant.card.vacay.otherDays': "Altri {count} giorni liberi quest'anno",
+ 'assistant.card.vacay.otherDays.one': "{count} altro giorno libero quest'anno",
+ 'assistant.card.vacay.allowance': 'Disponibilità',
+ 'assistant.card.vacay.meter': '{used} usati, {planned} previsti, {left} rimasti su {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Fine settimana, bloccato nel tuo piano',
+ 'assistant.card.vacay.weekend': 'Giorno del fine settimana, conta come ferie',
+ 'assistant.card.vacay.publicHoliday': 'Festività, non servono ferie',
+ 'assistant.card.vacay.companyHoliday': 'Ferie aziendali, non servono ferie',
+ 'assistant.card.vacay.companyHalf': 'Mezza giornata di ferie aziendali, resta mezza giornata',
+ 'assistant.card.vacay.notInPlan': 'Non è nel tuo piano ferie',
+ 'assistant.card.vacay.companyOff': 'Le ferie aziendali sono disattivate in questo piano',
+ 'assistant.card.vacay.twice': 'Elencato due volte, si annulla',
+ 'assistant.card.vacay.trip': 'Questo viaggio',
+ 'assistant.card.vacay.tapToSkip': 'Tocca per escludere',
+ 'assistant.card.vacay.tapToTake': 'Tocca per includere',
+ 'assistant.card.vacay.open': 'Apri in Vacay',
+ 'assistant.step.vacayEntries': 'Lettura dei tuoi giorni di ferie',
+ 'assistant.step.vacayStats': 'Conteggio dei giorni rimasti',
+ 'assistant.step.vacayHolidays': 'Ricerca delle festività',
+ 'assistant.tool.add_vacay_year': 'Aggiungi un anno a Vacay',
+ 'assistant.tool.delete_vacay_year': 'Rimuovi un anno da Vacay',
+ 'assistant.tool.update_vacay_plan': 'Modifica le impostazioni di Vacay',
+ 'assistant.tool.set_vacay_color': 'Cambia il tuo colore in Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Aggiungi un calendario delle festività',
+ 'assistant.tool.delete_holiday_calendar': 'Rimuovi un calendario delle festività',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/ja/assistantVacay.ts b/shared/src/i18n/ja/assistantVacay.ts
index 0a509629a..f57e19070 100644
--- a/shared/src/i18n/ja/assistantVacay.ts
+++ b/shared/src/i18n/ja/assistantVacay.ts
@@ -1,4 +1,45 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': '休暇を取る',
+ 'assistant.change.verb.removeVacationDay': '休暇を取り消す',
+ 'assistant.change.verb.changeVacationDay': '休暇を変更',
+ 'assistant.change.verb.setCompanyHoliday': '会社休日を設定',
+ 'assistant.change.verb.setVacationAllowance': '休暇日数を変更',
+ 'assistant.card.vacay.label': '休暇カレンダー',
+ 'assistant.card.vacay.fullDay': '終日',
+ 'assistant.card.vacay.halfDay': '半休',
+ 'assistant.card.vacay.compDay': '代休',
+ 'assistant.card.vacay.daysLeft': '残り{count}日',
+ 'assistant.card.vacay.usedDays': '{count}日使用済み',
+ 'assistant.card.vacay.usedOf': '{total}日中{used}日使用',
+ 'assistant.card.vacay.uses': '{count}日使う',
+ 'assistant.card.vacay.givesBack': '{count}日戻る',
+ 'assistant.card.vacay.over': '{count}日超過',
+ 'assistant.card.vacay.compDays': '代休{count}日',
+ 'assistant.card.vacay.otherDays': '今年はほかに{count}日休み',
+ 'assistant.card.vacay.allowance': '付与日数',
+ 'assistant.card.vacay.meter': '使用{used}、予定{planned}、{total}日中残り{left}',
+ 'assistant.card.vacay.weekendBlocked': '週末、プランでブロック中',
+ 'assistant.card.vacay.weekend': '週末、休暇として数える',
+ 'assistant.card.vacay.publicHoliday': '祝日、休暇は不要',
+ 'assistant.card.vacay.companyHoliday': '会社休日、休暇は不要',
+ 'assistant.card.vacay.companyHalf': '半日の会社休日、半日分が残る',
+ 'assistant.card.vacay.notInPlan': 'あなたの休暇プランにいません',
+ 'assistant.card.vacay.companyOff': 'このプランでは会社休日がオフです',
+ 'assistant.card.vacay.twice': '2回指定、相殺されます',
+ 'assistant.card.vacay.trip': 'この旅行',
+ 'assistant.card.vacay.tapToSkip': 'タップで除外',
+ 'assistant.card.vacay.tapToTake': 'タップで含める',
+ 'assistant.card.vacay.open': 'Vacayで開く',
+ 'assistant.step.vacayEntries': '休暇日を読み込み中',
+ 'assistant.step.vacayStats': '残り日数を計算中',
+ 'assistant.step.vacayHolidays': '祝日を確認中',
+ 'assistant.tool.add_vacay_year': 'Vacayに年を追加',
+ 'assistant.tool.delete_vacay_year': 'Vacayから年を削除',
+ 'assistant.tool.update_vacay_plan': 'Vacayの設定を変更',
+ 'assistant.tool.set_vacay_color': 'Vacayでの色を変更',
+ 'assistant.tool.add_holiday_calendar': '祝日カレンダーを追加',
+ 'assistant.tool.delete_holiday_calendar': '祝日カレンダーを削除',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/ko/assistantVacay.ts b/shared/src/i18n/ko/assistantVacay.ts
index 0a509629a..f9120349d 100644
--- a/shared/src/i18n/ko/assistantVacay.ts
+++ b/shared/src/i18n/ko/assistantVacay.ts
@@ -1,4 +1,45 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': '휴가 쓰기',
+ 'assistant.change.verb.removeVacationDay': '휴가 취소',
+ 'assistant.change.verb.changeVacationDay': '휴가 변경',
+ 'assistant.change.verb.setCompanyHoliday': '회사 휴일 지정',
+ 'assistant.change.verb.setVacationAllowance': '휴가 일수 변경',
+ 'assistant.card.vacay.label': '휴가 캘린더',
+ 'assistant.card.vacay.fullDay': '종일',
+ 'assistant.card.vacay.halfDay': '반차',
+ 'assistant.card.vacay.compDay': '대체휴무',
+ 'assistant.card.vacay.daysLeft': '{count}일 남음',
+ 'assistant.card.vacay.usedDays': '{count}일 사용',
+ 'assistant.card.vacay.usedOf': '{total}일 중 {used}일 사용',
+ 'assistant.card.vacay.uses': '{count}일 사용 예정',
+ 'assistant.card.vacay.givesBack': '{count}일 반환',
+ 'assistant.card.vacay.over': '{count}일 초과',
+ 'assistant.card.vacay.compDays': '대체휴무 {count}일',
+ 'assistant.card.vacay.otherDays': '올해 {count}일 더 쉼',
+ 'assistant.card.vacay.allowance': '휴가 일수',
+ 'assistant.card.vacay.meter': '{used} 사용, {planned} 예정, {total} 중 {left} 남음',
+ 'assistant.card.vacay.weekendBlocked': '주말, 플랜에서 차단됨',
+ 'assistant.card.vacay.weekend': '주말, 휴가로 계산됨',
+ 'assistant.card.vacay.publicHoliday': '공휴일, 휴가 불필요',
+ 'assistant.card.vacay.companyHoliday': '회사 휴일, 휴가 불필요',
+ 'assistant.card.vacay.companyHalf': '반일 회사 휴일, 반나절 남음',
+ 'assistant.card.vacay.notInPlan': '휴가 플랜에 없음',
+ 'assistant.card.vacay.companyOff': '이 플랜에서는 회사 휴일이 꺼져 있음',
+ 'assistant.card.vacay.twice': '두 번 지정됨, 서로 상쇄',
+ 'assistant.card.vacay.trip': '이번 여행',
+ 'assistant.card.vacay.tapToSkip': '탭하여 제외',
+ 'assistant.card.vacay.tapToTake': '탭하여 포함',
+ 'assistant.card.vacay.open': 'Vacay에서 열기',
+ 'assistant.step.vacayEntries': '휴가일 읽는 중',
+ 'assistant.step.vacayStats': '남은 일수 계산 중',
+ 'assistant.step.vacayHolidays': '공휴일 확인 중',
+ 'assistant.tool.add_vacay_year': 'Vacay에 연도 추가',
+ 'assistant.tool.delete_vacay_year': 'Vacay에서 연도 삭제',
+ 'assistant.tool.update_vacay_plan': 'Vacay 설정 변경',
+ 'assistant.tool.set_vacay_color': 'Vacay 색상 변경',
+ 'assistant.tool.add_holiday_calendar': '공휴일 캘린더 추가',
+ 'assistant.tool.delete_holiday_calendar': '공휴일 캘린더 삭제',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/nl/assistantVacay.ts b/shared/src/i18n/nl/assistantVacay.ts
index 0a509629a..2ea9f04ee 100644
--- a/shared/src/i18n/nl/assistantVacay.ts
+++ b/shared/src/i18n/nl/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Een vrije dag nemen',
+ 'assistant.change.verb.removeVacationDay': 'Een vrije dag schrappen',
+ 'assistant.change.verb.changeVacationDay': 'Een vrije dag wijzigen',
+ 'assistant.change.verb.setCompanyHoliday': 'Bedrijfsvakantie instellen',
+ 'assistant.change.verb.setVacationAllowance': 'Vakantierecht wijzigen',
+ 'assistant.card.vacay.label': 'Vakantiekalender',
+ 'assistant.card.vacay.fullDay': 'Hele dag',
+ 'assistant.card.vacay.halfDay': 'Halve dag',
+ 'assistant.card.vacay.compDay': 'Compensatiedag',
+ 'assistant.card.vacay.daysLeft': 'Nog {count} dagen over',
+ 'assistant.card.vacay.daysLeft.one': 'Nog {count} dag over',
+ 'assistant.card.vacay.usedDays': '{count} dagen opgenomen',
+ 'assistant.card.vacay.usedDays.one': '{count} dag opgenomen',
+ 'assistant.card.vacay.usedOf': '{used} van {total} opgenomen',
+ 'assistant.card.vacay.uses': 'Kost {count} dagen',
+ 'assistant.card.vacay.uses.one': 'Kost {count} dag',
+ 'assistant.card.vacay.givesBack': 'Geeft {count} dagen terug',
+ 'assistant.card.vacay.givesBack.one': 'Geeft {count} dag terug',
+ 'assistant.card.vacay.over': '{count} dagen te veel',
+ 'assistant.card.vacay.over.one': '{count} dag te veel',
+ 'assistant.card.vacay.compDays': '{count} compensatiedagen',
+ 'assistant.card.vacay.compDays.one': '{count} compensatiedag',
+ 'assistant.card.vacay.otherDays': 'Nog {count} vrije dagen dit jaar',
+ 'assistant.card.vacay.otherDays.one': 'Nog {count} vrije dag dit jaar',
+ 'assistant.card.vacay.allowance': 'Vakantierecht',
+ 'assistant.card.vacay.meter': '{used} opgenomen, {planned} gepland, {left} over van {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Weekend, geblokkeerd in je plan',
+ 'assistant.card.vacay.weekend': 'Weekenddag, telt als vakantie',
+ 'assistant.card.vacay.publicHoliday': 'Feestdag, geen vakantie nodig',
+ 'assistant.card.vacay.companyHoliday': 'Bedrijfsvakantie, geen vakantie nodig',
+ 'assistant.card.vacay.companyHalf': 'Halve bedrijfsvakantiedag, er blijft een halve dag over',
+ 'assistant.card.vacay.notInPlan': 'Niet in je vakantieplan',
+ 'assistant.card.vacay.companyOff': 'Bedrijfsvakantiedagen staan uit in dit plan',
+ 'assistant.card.vacay.twice': 'Twee keer genoemd, heft elkaar op',
+ 'assistant.card.vacay.trip': 'Deze reis',
+ 'assistant.card.vacay.tapToSkip': 'Tik om weg te laten',
+ 'assistant.card.vacay.tapToTake': 'Tik om mee te nemen',
+ 'assistant.card.vacay.open': 'Openen in Vacay',
+ 'assistant.step.vacayEntries': 'Je vakantiedagen lezen',
+ 'assistant.step.vacayStats': 'Je resterende dagen tellen',
+ 'assistant.step.vacayHolidays': 'Feestdagen opzoeken',
+ 'assistant.tool.add_vacay_year': 'Jaar aan Vacay toevoegen',
+ 'assistant.tool.delete_vacay_year': 'Jaar uit Vacay verwijderen',
+ 'assistant.tool.update_vacay_plan': 'Vacay-instellingen wijzigen',
+ 'assistant.tool.set_vacay_color': 'Je kleur in Vacay wijzigen',
+ 'assistant.tool.add_holiday_calendar': 'Feestdagenkalender toevoegen',
+ 'assistant.tool.delete_holiday_calendar': 'Feestdagenkalender verwijderen',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/pl/assistantVacay.ts b/shared/src/i18n/pl/assistantVacay.ts
index 0a509629a..2e746c4fb 100644
--- a/shared/src/i18n/pl/assistantVacay.ts
+++ b/shared/src/i18n/pl/assistantVacay.ts
@@ -1,4 +1,66 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Weź dzień urlopu',
+ 'assistant.change.verb.removeVacationDay': 'Anuluj dzień urlopu',
+ 'assistant.change.verb.changeVacationDay': 'Zmień dzień urlopu',
+ 'assistant.change.verb.setCompanyHoliday': 'Ustaw urlop firmowy',
+ 'assistant.change.verb.setVacationAllowance': 'Zmień wymiar urlopu',
+ 'assistant.card.vacay.label': 'Kalendarz urlopów',
+ 'assistant.card.vacay.fullDay': 'Cały dzień',
+ 'assistant.card.vacay.halfDay': 'Pół dnia',
+ 'assistant.card.vacay.compDay': 'Dzień odbioru',
+ 'assistant.card.vacay.daysLeft': 'Zostało {count} dnia',
+ 'assistant.card.vacay.daysLeft.one': 'Został {count} dzień',
+ 'assistant.card.vacay.daysLeft.few': 'Zostały {count} dni',
+ 'assistant.card.vacay.daysLeft.many': 'Zostało {count} dni',
+ 'assistant.card.vacay.usedDays': 'Wykorzystano {count} dnia',
+ 'assistant.card.vacay.usedDays.one': 'Wykorzystano {count} dzień',
+ 'assistant.card.vacay.usedDays.few': 'Wykorzystano {count} dni',
+ 'assistant.card.vacay.usedDays.many': 'Wykorzystano {count} dni',
+ 'assistant.card.vacay.usedOf': 'Wykorzystano {used} z {total}',
+ 'assistant.card.vacay.uses': 'Zajmie {count} dnia',
+ 'assistant.card.vacay.uses.one': 'Zajmie {count} dzień',
+ 'assistant.card.vacay.uses.few': 'Zajmie {count} dni',
+ 'assistant.card.vacay.uses.many': 'Zajmie {count} dni',
+ 'assistant.card.vacay.givesBack': 'Zwróci {count} dnia',
+ 'assistant.card.vacay.givesBack.one': 'Zwróci {count} dzień',
+ 'assistant.card.vacay.givesBack.few': 'Zwróci {count} dni',
+ 'assistant.card.vacay.givesBack.many': 'Zwróci {count} dni',
+ 'assistant.card.vacay.over': 'O {count} dnia za dużo',
+ 'assistant.card.vacay.over.one': 'O {count} dzień za dużo',
+ 'assistant.card.vacay.over.few': 'O {count} dni za dużo',
+ 'assistant.card.vacay.over.many': 'O {count} dni za dużo',
+ 'assistant.card.vacay.compDays': '{count} dnia odbioru',
+ 'assistant.card.vacay.compDays.one': '{count} dzień odbioru',
+ 'assistant.card.vacay.compDays.few': '{count} dni odbioru',
+ 'assistant.card.vacay.compDays.many': '{count} dni odbioru',
+ 'assistant.card.vacay.otherDays': 'Jeszcze {count} dnia wolnego w tym roku',
+ 'assistant.card.vacay.otherDays.one': 'Jeszcze {count} dzień wolny w tym roku',
+ 'assistant.card.vacay.otherDays.few': 'Jeszcze {count} dni wolne w tym roku',
+ 'assistant.card.vacay.otherDays.many': 'Jeszcze {count} dni wolnych w tym roku',
+ 'assistant.card.vacay.allowance': 'Wymiar',
+ 'assistant.card.vacay.meter': 'Wykorzystano {used}, zaplanowano {planned}, zostało {left} z {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Weekend, zablokowany w Twoim planie',
+ 'assistant.card.vacay.weekend': 'Dzień weekendu, liczy się jako urlop',
+ 'assistant.card.vacay.publicHoliday': 'Święto, urlop niepotrzebny',
+ 'assistant.card.vacay.companyHoliday': 'Urlop firmowy, urlop niepotrzebny',
+ 'assistant.card.vacay.companyHalf': 'Pół dnia urlopu firmowego, zostaje pół dnia',
+ 'assistant.card.vacay.notInPlan': 'Spoza Twojego planu urlopów',
+ 'assistant.card.vacay.companyOff': 'Urlop firmowy jest wyłączony w tym planie',
+ 'assistant.card.vacay.twice': 'Podane dwa razy, znosi się',
+ 'assistant.card.vacay.trip': 'Ta podróż',
+ 'assistant.card.vacay.tapToSkip': 'Dotknij, aby pominąć',
+ 'assistant.card.vacay.tapToTake': 'Dotknij, aby uwzględnić',
+ 'assistant.card.vacay.open': 'Otwórz w Vacay',
+ 'assistant.step.vacayEntries': 'Czytam Twoje dni urlopu',
+ 'assistant.step.vacayStats': 'Liczę pozostałe dni',
+ 'assistant.step.vacayHolidays': 'Szukam świąt',
+ 'assistant.tool.add_vacay_year': 'Dodaj rok do Vacay',
+ 'assistant.tool.delete_vacay_year': 'Usuń rok z Vacay',
+ 'assistant.tool.update_vacay_plan': 'Zmień ustawienia Vacay',
+ 'assistant.tool.set_vacay_color': 'Zmień swój kolor w Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Dodaj kalendarz świąt',
+ 'assistant.tool.delete_holiday_calendar': 'Usuń kalendarz świąt',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/ru/assistantVacay.ts b/shared/src/i18n/ru/assistantVacay.ts
index 0a509629a..95d4397cc 100644
--- a/shared/src/i18n/ru/assistantVacay.ts
+++ b/shared/src/i18n/ru/assistantVacay.ts
@@ -1,4 +1,66 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Взять день отпуска',
+ 'assistant.change.verb.removeVacationDay': 'Отменить день отпуска',
+ 'assistant.change.verb.changeVacationDay': 'Изменить день отпуска',
+ 'assistant.change.verb.setCompanyHoliday': 'Назначить корпоративный выходной',
+ 'assistant.change.verb.setVacationAllowance': 'Изменить право на отпуск',
+ 'assistant.card.vacay.label': 'Календарь отпусков',
+ 'assistant.card.vacay.fullDay': 'Целый день',
+ 'assistant.card.vacay.halfDay': 'Полдня',
+ 'assistant.card.vacay.compDay': 'Отгул',
+ 'assistant.card.vacay.daysLeft': 'Осталось {count} дня',
+ 'assistant.card.vacay.daysLeft.one': 'Остался {count} день',
+ 'assistant.card.vacay.daysLeft.few': 'Осталось {count} дня',
+ 'assistant.card.vacay.daysLeft.many': 'Осталось {count} дней',
+ 'assistant.card.vacay.usedDays': 'Использовано {count} дня',
+ 'assistant.card.vacay.usedDays.one': 'Использован {count} день',
+ 'assistant.card.vacay.usedDays.few': 'Использовано {count} дня',
+ 'assistant.card.vacay.usedDays.many': 'Использовано {count} дней',
+ 'assistant.card.vacay.usedOf': 'Использовано {used} из {total}',
+ 'assistant.card.vacay.uses': 'Займёт {count} дня',
+ 'assistant.card.vacay.uses.one': 'Займёт {count} день',
+ 'assistant.card.vacay.uses.few': 'Займёт {count} дня',
+ 'assistant.card.vacay.uses.many': 'Займёт {count} дней',
+ 'assistant.card.vacay.givesBack': 'Вернёт {count} дня',
+ 'assistant.card.vacay.givesBack.one': 'Вернёт {count} день',
+ 'assistant.card.vacay.givesBack.few': 'Вернёт {count} дня',
+ 'assistant.card.vacay.givesBack.many': 'Вернёт {count} дней',
+ 'assistant.card.vacay.over': 'Превышение на {count} дня',
+ 'assistant.card.vacay.over.one': 'Превышение на {count} день',
+ 'assistant.card.vacay.over.few': 'Превышение на {count} дня',
+ 'assistant.card.vacay.over.many': 'Превышение на {count} дней',
+ 'assistant.card.vacay.compDays': '{count} дня отгула',
+ 'assistant.card.vacay.compDays.one': '{count} день отгула',
+ 'assistant.card.vacay.compDays.few': '{count} дня отгула',
+ 'assistant.card.vacay.compDays.many': '{count} дней отгула',
+ 'assistant.card.vacay.otherDays': 'Ещё {count} дня отдыха в этом году',
+ 'assistant.card.vacay.otherDays.one': 'Ещё {count} день отдыха в этом году',
+ 'assistant.card.vacay.otherDays.few': 'Ещё {count} дня отдыха в этом году',
+ 'assistant.card.vacay.otherDays.many': 'Ещё {count} дней отдыха в этом году',
+ 'assistant.card.vacay.allowance': 'Право на отпуск',
+ 'assistant.card.vacay.meter': 'Использовано {used}, запланировано {planned}, осталось {left} из {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Выходные, заблокированы в вашем плане',
+ 'assistant.card.vacay.weekend': 'Выходной день, считается отпуском',
+ 'assistant.card.vacay.publicHoliday': 'Праздник, отпуск не нужен',
+ 'assistant.card.vacay.companyHoliday': 'Корпоративный выходной, отпуск не нужен',
+ 'assistant.card.vacay.companyHalf': 'Полдня корпоративного выходного, остаётся полдня',
+ 'assistant.card.vacay.notInPlan': 'Не в вашем плане отпусков',
+ 'assistant.card.vacay.companyOff': 'Корпоративные выходные в этом плане выключены',
+ 'assistant.card.vacay.twice': 'Указано дважды, взаимно отменяется',
+ 'assistant.card.vacay.trip': 'Эта поездка',
+ 'assistant.card.vacay.tapToSkip': 'Нажмите, чтобы исключить',
+ 'assistant.card.vacay.tapToTake': 'Нажмите, чтобы включить',
+ 'assistant.card.vacay.open': 'Открыть в Vacay',
+ 'assistant.step.vacayEntries': 'Читаю ваши дни отпуска',
+ 'assistant.step.vacayStats': 'Считаю оставшиеся дни',
+ 'assistant.step.vacayHolidays': 'Ищу праздники',
+ 'assistant.tool.add_vacay_year': 'Добавить год в Vacay',
+ 'assistant.tool.delete_vacay_year': 'Удалить год из Vacay',
+ 'assistant.tool.update_vacay_plan': 'Изменить настройки Vacay',
+ 'assistant.tool.set_vacay_color': 'Изменить свой цвет в Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Добавить календарь праздников',
+ 'assistant.tool.delete_holiday_calendar': 'Удалить календарь праздников',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/sk/assistantVacay.ts b/shared/src/i18n/sk/assistantVacay.ts
index 0a509629a..7ce8e3a10 100644
--- a/shared/src/i18n/sk/assistantVacay.ts
+++ b/shared/src/i18n/sk/assistantVacay.ts
@@ -1,4 +1,59 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Vziať si deň dovolenky',
+ 'assistant.change.verb.removeVacationDay': 'Zrušiť deň dovolenky',
+ 'assistant.change.verb.changeVacationDay': 'Zmeniť deň dovolenky',
+ 'assistant.change.verb.setCompanyHoliday': 'Nastaviť firemné voľno',
+ 'assistant.change.verb.setVacationAllowance': 'Zmeniť nárok na dovolenku',
+ 'assistant.card.vacay.label': 'Kalendár dovolenky',
+ 'assistant.card.vacay.fullDay': 'Celý deň',
+ 'assistant.card.vacay.halfDay': 'Pol dňa',
+ 'assistant.card.vacay.compDay': 'Deň náhradného voľna',
+ 'assistant.card.vacay.daysLeft': 'Zostáva {count} dní',
+ 'assistant.card.vacay.daysLeft.one': 'Zostáva {count} deň',
+ 'assistant.card.vacay.daysLeft.few': 'Zostávajú {count} dni',
+ 'assistant.card.vacay.usedDays': 'Vyčerpaných {count} dní',
+ 'assistant.card.vacay.usedDays.one': 'Vyčerpaný {count} deň',
+ 'assistant.card.vacay.usedDays.few': 'Vyčerpané {count} dni',
+ 'assistant.card.vacay.usedOf': 'Vyčerpané {used} z {total}',
+ 'assistant.card.vacay.uses': 'Spotrebuje {count} dní',
+ 'assistant.card.vacay.uses.one': 'Spotrebuje {count} deň',
+ 'assistant.card.vacay.uses.few': 'Spotrebuje {count} dni',
+ 'assistant.card.vacay.givesBack': 'Vráti {count} dní',
+ 'assistant.card.vacay.givesBack.one': 'Vráti {count} deň',
+ 'assistant.card.vacay.givesBack.few': 'Vráti {count} dni',
+ 'assistant.card.vacay.over': 'O {count} dní navyše',
+ 'assistant.card.vacay.over.one': 'O {count} deň navyše',
+ 'assistant.card.vacay.over.few': 'O {count} dni navyše',
+ 'assistant.card.vacay.compDays': '{count} dní náhradného voľna',
+ 'assistant.card.vacay.compDays.one': '{count} deň náhradného voľna',
+ 'assistant.card.vacay.compDays.few': '{count} dni náhradného voľna',
+ 'assistant.card.vacay.otherDays': 'Tento rok ešte {count} dní voľna',
+ 'assistant.card.vacay.otherDays.one': 'Tento rok ešte {count} deň voľna',
+ 'assistant.card.vacay.otherDays.few': 'Tento rok ešte {count} dni voľna',
+ 'assistant.card.vacay.allowance': 'Nárok',
+ 'assistant.card.vacay.meter': 'Vyčerpané {used}, naplánované {planned}, zostáva {left} z {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Víkend, vo tvojom pláne zablokovaný',
+ 'assistant.card.vacay.weekend': 'Víkendový deň, počíta sa ako dovolenka',
+ 'assistant.card.vacay.publicHoliday': 'Štátny sviatok, dovolenka nie je potrebná',
+ 'assistant.card.vacay.companyHoliday': 'Firemné voľno, dovolenka nie je potrebná',
+ 'assistant.card.vacay.companyHalf': 'Pol dňa firemného voľna, zostáva pol dňa',
+ 'assistant.card.vacay.notInPlan': 'Nie je v tvojom pláne dovolenky',
+ 'assistant.card.vacay.companyOff': 'Firemné voľno je v tomto pláne vypnuté',
+ 'assistant.card.vacay.twice': 'Uvedené dvakrát, ruší sa',
+ 'assistant.card.vacay.trip': 'Táto cesta',
+ 'assistant.card.vacay.tapToSkip': 'Ťuknutím vynecháte',
+ 'assistant.card.vacay.tapToTake': 'Ťuknutím zahrniete',
+ 'assistant.card.vacay.open': 'Otvoriť vo Vacay',
+ 'assistant.step.vacayEntries': 'Načítavam tvoje dni dovolenky',
+ 'assistant.step.vacayStats': 'Počítam zostávajúce dni',
+ 'assistant.step.vacayHolidays': 'Hľadám štátne sviatky',
+ 'assistant.tool.add_vacay_year': 'Pridať rok do Vacay',
+ 'assistant.tool.delete_vacay_year': 'Odobrať rok z Vacay',
+ 'assistant.tool.update_vacay_plan': 'Zmeniť nastavenia Vacay',
+ 'assistant.tool.set_vacay_color': 'Zmeniť svoju farbu vo Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Pridať kalendár sviatkov',
+ 'assistant.tool.delete_holiday_calendar': 'Odobrať kalendár sviatkov',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/sv/assistantVacay.ts b/shared/src/i18n/sv/assistantVacay.ts
index 0a509629a..ec3c49e50 100644
--- a/shared/src/i18n/sv/assistantVacay.ts
+++ b/shared/src/i18n/sv/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Ta en ledig dag',
+ 'assistant.change.verb.removeVacationDay': 'Stryk en ledig dag',
+ 'assistant.change.verb.changeVacationDay': 'Ändra en ledig dag',
+ 'assistant.change.verb.setCompanyHoliday': 'Ange en företagshelg',
+ 'assistant.change.verb.setVacationAllowance': 'Ändra semesterrätten',
+ 'assistant.card.vacay.label': 'Semesterkalender',
+ 'assistant.card.vacay.fullDay': 'Hel dag',
+ 'assistant.card.vacay.halfDay': 'Halvdag',
+ 'assistant.card.vacay.compDay': 'Kompdag',
+ 'assistant.card.vacay.daysLeft': '{count} dagar kvar',
+ 'assistant.card.vacay.daysLeft.one': '{count} dag kvar',
+ 'assistant.card.vacay.usedDays': '{count} dagar uttagna',
+ 'assistant.card.vacay.usedDays.one': '{count} dag uttagen',
+ 'assistant.card.vacay.usedOf': '{used} av {total} uttagna',
+ 'assistant.card.vacay.uses': 'Tar {count} dagar',
+ 'assistant.card.vacay.uses.one': 'Tar {count} dag',
+ 'assistant.card.vacay.givesBack': 'Ger tillbaka {count} dagar',
+ 'assistant.card.vacay.givesBack.one': 'Ger tillbaka {count} dag',
+ 'assistant.card.vacay.over': '{count} dagar för mycket',
+ 'assistant.card.vacay.over.one': '{count} dag för mycket',
+ 'assistant.card.vacay.compDays': '{count} kompdagar',
+ 'assistant.card.vacay.compDays.one': '{count} kompdag',
+ 'assistant.card.vacay.otherDays': '{count} lediga dagar till i år',
+ 'assistant.card.vacay.otherDays.one': '{count} ledig dag till i år',
+ 'assistant.card.vacay.allowance': 'Semesterrätt',
+ 'assistant.card.vacay.meter': '{used} uttagna, {planned} planerade, {left} kvar av {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Veckoslut, spärrat i din plan',
+ 'assistant.card.vacay.weekend': 'Veckoslut, räknas som semester',
+ 'assistant.card.vacay.publicHoliday': 'Helgdag, ingen semester behövs',
+ 'assistant.card.vacay.companyHoliday': 'Företagshelg, ingen semester behövs',
+ 'assistant.card.vacay.companyHalf': 'Halv företagshelg, en halv dag finns kvar',
+ 'assistant.card.vacay.notInPlan': 'Inte med i din semesterplan',
+ 'assistant.card.vacay.companyOff': 'Företagshelger är avstängda i den här planen',
+ 'assistant.card.vacay.twice': 'Listad två gånger, tar ut varandra',
+ 'assistant.card.vacay.trip': 'Den här resan',
+ 'assistant.card.vacay.tapToSkip': 'Tryck för att utesluta',
+ 'assistant.card.vacay.tapToTake': 'Tryck för att ta med',
+ 'assistant.card.vacay.open': 'Öppna i Vacay',
+ 'assistant.step.vacayEntries': 'Läser dina semesterdagar',
+ 'assistant.step.vacayStats': 'Räknar dina återstående dagar',
+ 'assistant.step.vacayHolidays': 'Slår upp helgdagar',
+ 'assistant.tool.add_vacay_year': 'Lägg till ett år i Vacay',
+ 'assistant.tool.delete_vacay_year': 'Ta bort ett år från Vacay',
+ 'assistant.tool.update_vacay_plan': 'Ändra Vacay-inställningarna',
+ 'assistant.tool.set_vacay_color': 'Ändra din färg i Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Lägg till en helgdagskalender',
+ 'assistant.tool.delete_holiday_calendar': 'Ta bort en helgdagskalender',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/th/assistantVacay.ts b/shared/src/i18n/th/assistantVacay.ts
index 0a509629a..bee7f5c50 100644
--- a/shared/src/i18n/th/assistantVacay.ts
+++ b/shared/src/i18n/th/assistantVacay.ts
@@ -1,4 +1,45 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'ลาพักร้อนหนึ่งวัน',
+ 'assistant.change.verb.removeVacationDay': 'ยกเลิกวันลา',
+ 'assistant.change.verb.changeVacationDay': 'เปลี่ยนวันลา',
+ 'assistant.change.verb.setCompanyHoliday': 'กำหนดวันหยุดบริษัท',
+ 'assistant.change.verb.setVacationAllowance': 'เปลี่ยนสิทธิวันลา',
+ 'assistant.card.vacay.label': 'ปฏิทินวันลา',
+ 'assistant.card.vacay.fullDay': 'เต็มวัน',
+ 'assistant.card.vacay.halfDay': 'ครึ่งวัน',
+ 'assistant.card.vacay.compDay': 'วันหยุดชดเชย',
+ 'assistant.card.vacay.daysLeft': 'เหลือ {count} วัน',
+ 'assistant.card.vacay.usedDays': 'ใช้ไปแล้ว {count} วัน',
+ 'assistant.card.vacay.usedOf': 'ใช้ไป {used} จาก {total}',
+ 'assistant.card.vacay.uses': 'ใช้ {count} วัน',
+ 'assistant.card.vacay.givesBack': 'คืน {count} วัน',
+ 'assistant.card.vacay.over': 'เกิน {count} วัน',
+ 'assistant.card.vacay.compDays': 'วันหยุดชดเชย {count} วัน',
+ 'assistant.card.vacay.otherDays': 'ปีนี้หยุดอีก {count} วัน',
+ 'assistant.card.vacay.allowance': 'สิทธิวันลา',
+ 'assistant.card.vacay.meter': 'ใช้ไป {used} วางแผน {planned} เหลือ {left} จาก {total}',
+ 'assistant.card.vacay.weekendBlocked': 'วันหยุดสุดสัปดาห์ ถูกบล็อกในแผนของคุณ',
+ 'assistant.card.vacay.weekend': 'วันหยุดสุดสัปดาห์ นับเป็นวันลา',
+ 'assistant.card.vacay.publicHoliday': 'วันหยุดนักขัตฤกษ์ ไม่ต้องลา',
+ 'assistant.card.vacay.companyHoliday': 'วันหยุดบริษัท ไม่ต้องลา',
+ 'assistant.card.vacay.companyHalf': 'วันหยุดบริษัทครึ่งวัน เหลืออีกครึ่งวัน',
+ 'assistant.card.vacay.notInPlan': 'ไม่อยู่ในแผนวันลาของคุณ',
+ 'assistant.card.vacay.companyOff': 'วันหยุดบริษัทถูกปิดอยู่ในแผนนี้',
+ 'assistant.card.vacay.twice': 'ระบุซ้ำสองครั้ง หักล้างกัน',
+ 'assistant.card.vacay.trip': 'ทริปนี้',
+ 'assistant.card.vacay.tapToSkip': 'แตะเพื่อตัดออก',
+ 'assistant.card.vacay.tapToTake': 'แตะเพื่อรวมไว้',
+ 'assistant.card.vacay.open': 'เปิดใน Vacay',
+ 'assistant.step.vacayEntries': 'กำลังอ่านวันลาของคุณ',
+ 'assistant.step.vacayStats': 'กำลังนับวันที่เหลือ',
+ 'assistant.step.vacayHolidays': 'กำลังค้นหาวันหยุดนักขัตฤกษ์',
+ 'assistant.tool.add_vacay_year': 'เพิ่มปีใน Vacay',
+ 'assistant.tool.delete_vacay_year': 'ลบปีออกจาก Vacay',
+ 'assistant.tool.update_vacay_plan': 'เปลี่ยนการตั้งค่า Vacay',
+ 'assistant.tool.set_vacay_color': 'เปลี่ยนสีของคุณใน Vacay',
+ 'assistant.tool.add_holiday_calendar': 'เพิ่มปฏิทินวันหยุด',
+ 'assistant.tool.delete_holiday_calendar': 'ลบปฏิทินวันหยุด',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/tr/assistantVacay.ts b/shared/src/i18n/tr/assistantVacay.ts
index 0a509629a..4b469487a 100644
--- a/shared/src/i18n/tr/assistantVacay.ts
+++ b/shared/src/i18n/tr/assistantVacay.ts
@@ -1,4 +1,52 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'İzin günü al',
+ 'assistant.change.verb.removeVacationDay': 'İzin gününü iptal et',
+ 'assistant.change.verb.changeVacationDay': 'İzin gününü değiştir',
+ 'assistant.change.verb.setCompanyHoliday': 'Şirket tatili belirle',
+ 'assistant.change.verb.setVacationAllowance': 'İzin hakkını değiştir',
+ 'assistant.card.vacay.label': 'İzin takvimi',
+ 'assistant.card.vacay.fullDay': 'Tam gün',
+ 'assistant.card.vacay.halfDay': 'Yarım gün',
+ 'assistant.card.vacay.compDay': 'Serbest zaman günü',
+ 'assistant.card.vacay.daysLeft': '{count} gün kaldı',
+ 'assistant.card.vacay.daysLeft.one': '{count} gün kaldı',
+ 'assistant.card.vacay.usedDays': '{count} gün kullanıldı',
+ 'assistant.card.vacay.usedDays.one': '{count} gün kullanıldı',
+ 'assistant.card.vacay.usedOf': '{total} günün {used} günü kullanıldı',
+ 'assistant.card.vacay.uses': '{count} gün kullanır',
+ 'assistant.card.vacay.uses.one': '{count} gün kullanır',
+ 'assistant.card.vacay.givesBack': '{count} gün geri verir',
+ 'assistant.card.vacay.givesBack.one': '{count} gün geri verir',
+ 'assistant.card.vacay.over': 'Haktan {count} gün fazla',
+ 'assistant.card.vacay.over.one': 'Haktan {count} gün fazla',
+ 'assistant.card.vacay.compDays': '{count} serbest zaman günü',
+ 'assistant.card.vacay.compDays.one': '{count} serbest zaman günü',
+ 'assistant.card.vacay.otherDays': 'Bu yıl {count} izin günü daha',
+ 'assistant.card.vacay.otherDays.one': 'Bu yıl {count} izin günü daha',
+ 'assistant.card.vacay.allowance': 'Hakediş',
+ 'assistant.card.vacay.meter': '{used} kullanıldı, {planned} planlandı, {total} günden {left} kaldı',
+ 'assistant.card.vacay.weekendBlocked': 'Hafta sonu, planında engelli',
+ 'assistant.card.vacay.weekend': 'Hafta sonu günü, izin sayılır',
+ 'assistant.card.vacay.publicHoliday': 'Resmi tatil, izin gerekmez',
+ 'assistant.card.vacay.companyHoliday': 'Şirket tatili, izin gerekmez',
+ 'assistant.card.vacay.companyHalf': 'Yarım şirket tatili, yarım gün kalır',
+ 'assistant.card.vacay.notInPlan': 'İzin planında değil',
+ 'assistant.card.vacay.companyOff': 'Bu planda şirket tatilleri kapalı',
+ 'assistant.card.vacay.twice': 'İki kez listelendi, birbirini iptal eder',
+ 'assistant.card.vacay.trip': 'Bu seyahat',
+ 'assistant.card.vacay.tapToSkip': 'Dışarıda bırakmak için dokun',
+ 'assistant.card.vacay.tapToTake': 'Dahil etmek için dokun',
+ 'assistant.card.vacay.open': "Vacay'de aç",
+ 'assistant.step.vacayEntries': 'İzin günlerin okunuyor',
+ 'assistant.step.vacayStats': 'Kalan günlerin sayılıyor',
+ 'assistant.step.vacayHolidays': 'Resmi tatiller aranıyor',
+ 'assistant.tool.add_vacay_year': "Vacay'e yıl ekle",
+ 'assistant.tool.delete_vacay_year': "Vacay'den yıl kaldır",
+ 'assistant.tool.update_vacay_plan': 'Vacay ayarlarını değiştir',
+ 'assistant.tool.set_vacay_color': 'Vacay rengini değiştir',
+ 'assistant.tool.add_holiday_calendar': 'Tatil takvimi ekle',
+ 'assistant.tool.delete_holiday_calendar': 'Tatil takvimini kaldır',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/uk/assistantVacay.ts b/shared/src/i18n/uk/assistantVacay.ts
index 0a509629a..5bf22669c 100644
--- a/shared/src/i18n/uk/assistantVacay.ts
+++ b/shared/src/i18n/uk/assistantVacay.ts
@@ -1,4 +1,66 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Взяти день відпустки',
+ 'assistant.change.verb.removeVacationDay': 'Скасувати день відпустки',
+ 'assistant.change.verb.changeVacationDay': 'Змінити день відпустки',
+ 'assistant.change.verb.setCompanyHoliday': 'Призначити корпоративний вихідний',
+ 'assistant.change.verb.setVacationAllowance': 'Змінити право на відпустку',
+ 'assistant.card.vacay.label': 'Календар відпусток',
+ 'assistant.card.vacay.fullDay': 'Цілий день',
+ 'assistant.card.vacay.halfDay': 'Пів дня',
+ 'assistant.card.vacay.compDay': 'Відгул',
+ 'assistant.card.vacay.daysLeft': 'Залишилося {count} дня',
+ 'assistant.card.vacay.daysLeft.one': 'Залишився {count} день',
+ 'assistant.card.vacay.daysLeft.few': 'Залишилося {count} дні',
+ 'assistant.card.vacay.daysLeft.many': 'Залишилося {count} днів',
+ 'assistant.card.vacay.usedDays': 'Використано {count} дня',
+ 'assistant.card.vacay.usedDays.one': 'Використано {count} день',
+ 'assistant.card.vacay.usedDays.few': 'Використано {count} дні',
+ 'assistant.card.vacay.usedDays.many': 'Використано {count} днів',
+ 'assistant.card.vacay.usedOf': 'Використано {used} з {total}',
+ 'assistant.card.vacay.uses': 'Займе {count} дня',
+ 'assistant.card.vacay.uses.one': 'Займе {count} день',
+ 'assistant.card.vacay.uses.few': 'Займе {count} дні',
+ 'assistant.card.vacay.uses.many': 'Займе {count} днів',
+ 'assistant.card.vacay.givesBack': 'Поверне {count} дня',
+ 'assistant.card.vacay.givesBack.one': 'Поверне {count} день',
+ 'assistant.card.vacay.givesBack.few': 'Поверне {count} дні',
+ 'assistant.card.vacay.givesBack.many': 'Поверне {count} днів',
+ 'assistant.card.vacay.over': 'Перевищення на {count} дня',
+ 'assistant.card.vacay.over.one': 'Перевищення на {count} день',
+ 'assistant.card.vacay.over.few': 'Перевищення на {count} дні',
+ 'assistant.card.vacay.over.many': 'Перевищення на {count} днів',
+ 'assistant.card.vacay.compDays': '{count} дня відгулу',
+ 'assistant.card.vacay.compDays.one': '{count} день відгулу',
+ 'assistant.card.vacay.compDays.few': '{count} дні відгулу',
+ 'assistant.card.vacay.compDays.many': '{count} днів відгулу',
+ 'assistant.card.vacay.otherDays': 'Ще {count} дня відпочинку цього року',
+ 'assistant.card.vacay.otherDays.one': 'Ще {count} день відпочинку цього року',
+ 'assistant.card.vacay.otherDays.few': 'Ще {count} дні відпочинку цього року',
+ 'assistant.card.vacay.otherDays.many': 'Ще {count} днів відпочинку цього року',
+ 'assistant.card.vacay.allowance': 'Право на відпустку',
+ 'assistant.card.vacay.meter': 'Використано {used}, заплановано {planned}, залишилося {left} з {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Вихідні, заблоковані у вашому плані',
+ 'assistant.card.vacay.weekend': 'Вихідний день, рахується як відпустка',
+ 'assistant.card.vacay.publicHoliday': 'Свято, відпустка не потрібна',
+ 'assistant.card.vacay.companyHoliday': 'Корпоративний вихідний, відпустка не потрібна',
+ 'assistant.card.vacay.companyHalf': 'Пів дня корпоративного вихідного, лишається пів дня',
+ 'assistant.card.vacay.notInPlan': 'Не у вашому плані відпусток',
+ 'assistant.card.vacay.companyOff': 'Корпоративні вихідні в цьому плані вимкнено',
+ 'assistant.card.vacay.twice': 'Указано двічі, взаємно скасовується',
+ 'assistant.card.vacay.trip': 'Ця подорож',
+ 'assistant.card.vacay.tapToSkip': 'Торкніться, щоб виключити',
+ 'assistant.card.vacay.tapToTake': 'Торкніться, щоб включити',
+ 'assistant.card.vacay.open': 'Відкрити у Vacay',
+ 'assistant.step.vacayEntries': 'Читаю ваші дні відпустки',
+ 'assistant.step.vacayStats': 'Рахую дні, що залишилися',
+ 'assistant.step.vacayHolidays': 'Шукаю свята',
+ 'assistant.tool.add_vacay_year': 'Додати рік до Vacay',
+ 'assistant.tool.delete_vacay_year': 'Видалити рік з Vacay',
+ 'assistant.tool.update_vacay_plan': 'Змінити налаштування Vacay',
+ 'assistant.tool.set_vacay_color': 'Змінити свій колір у Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Додати календар свят',
+ 'assistant.tool.delete_holiday_calendar': 'Видалити календар свят',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/vi/assistantVacay.ts b/shared/src/i18n/vi/assistantVacay.ts
index 0a509629a..503397fc9 100644
--- a/shared/src/i18n/vi/assistantVacay.ts
+++ b/shared/src/i18n/vi/assistantVacay.ts
@@ -1,4 +1,45 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': 'Nghỉ một ngày phép',
+ 'assistant.change.verb.removeVacationDay': 'Hủy một ngày phép',
+ 'assistant.change.verb.changeVacationDay': 'Đổi một ngày phép',
+ 'assistant.change.verb.setCompanyHoliday': 'Đặt ngày nghỉ công ty',
+ 'assistant.change.verb.setVacationAllowance': 'Đổi số ngày phép',
+ 'assistant.card.vacay.label': 'Lịch nghỉ phép',
+ 'assistant.card.vacay.fullDay': 'Cả ngày',
+ 'assistant.card.vacay.halfDay': 'Nửa ngày',
+ 'assistant.card.vacay.compDay': 'Ngày nghỉ bù',
+ 'assistant.card.vacay.daysLeft': 'Còn {count} ngày',
+ 'assistant.card.vacay.usedDays': 'Đã dùng {count} ngày',
+ 'assistant.card.vacay.usedOf': 'Đã dùng {used} trên {total}',
+ 'assistant.card.vacay.uses': 'Dùng {count} ngày',
+ 'assistant.card.vacay.givesBack': 'Trả lại {count} ngày',
+ 'assistant.card.vacay.over': 'Vượt {count} ngày',
+ 'assistant.card.vacay.compDays': '{count} ngày nghỉ bù',
+ 'assistant.card.vacay.otherDays': 'Năm nay còn nghỉ thêm {count} ngày',
+ 'assistant.card.vacay.allowance': 'Số ngày phép',
+ 'assistant.card.vacay.meter': 'Đã dùng {used}, dự kiến {planned}, còn {left} trên {total}',
+ 'assistant.card.vacay.weekendBlocked': 'Cuối tuần, bị chặn trong kế hoạch của bạn',
+ 'assistant.card.vacay.weekend': 'Ngày cuối tuần, tính là ngày phép',
+ 'assistant.card.vacay.publicHoliday': 'Ngày lễ, không cần nghỉ phép',
+ 'assistant.card.vacay.companyHoliday': 'Ngày nghỉ công ty, không cần nghỉ phép',
+ 'assistant.card.vacay.companyHalf': 'Nửa ngày nghỉ công ty, còn nửa ngày',
+ 'assistant.card.vacay.notInPlan': 'Không có trong kế hoạch nghỉ phép của bạn',
+ 'assistant.card.vacay.companyOff': 'Ngày nghỉ công ty đang tắt trong kế hoạch này',
+ 'assistant.card.vacay.twice': 'Ghi hai lần, triệt tiêu nhau',
+ 'assistant.card.vacay.trip': 'Chuyến đi này',
+ 'assistant.card.vacay.tapToSkip': 'Chạm để bỏ ra',
+ 'assistant.card.vacay.tapToTake': 'Chạm để đưa vào',
+ 'assistant.card.vacay.open': 'Mở trong Vacay',
+ 'assistant.step.vacayEntries': 'Đang đọc ngày nghỉ của bạn',
+ 'assistant.step.vacayStats': 'Đang đếm số ngày còn lại',
+ 'assistant.step.vacayHolidays': 'Đang tra ngày lễ',
+ 'assistant.tool.add_vacay_year': 'Thêm năm vào Vacay',
+ 'assistant.tool.delete_vacay_year': 'Xóa năm khỏi Vacay',
+ 'assistant.tool.update_vacay_plan': 'Đổi cài đặt Vacay',
+ 'assistant.tool.set_vacay_color': 'Đổi màu của bạn trong Vacay',
+ 'assistant.tool.add_holiday_calendar': 'Thêm lịch ngày lễ',
+ 'assistant.tool.delete_holiday_calendar': 'Xóa lịch ngày lễ',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/zh-TW/assistantVacay.ts b/shared/src/i18n/zh-TW/assistantVacay.ts
index 0a509629a..bc908ecff 100644
--- a/shared/src/i18n/zh-TW/assistantVacay.ts
+++ b/shared/src/i18n/zh-TW/assistantVacay.ts
@@ -1,4 +1,45 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': '請一天假',
+ 'assistant.change.verb.removeVacationDay': '取消一天假',
+ 'assistant.change.verb.changeVacationDay': '更改一天假',
+ 'assistant.change.verb.setCompanyHoliday': '設定公司假日',
+ 'assistant.change.verb.setVacationAllowance': '更改年假額度',
+ 'assistant.card.vacay.label': '假期行事曆',
+ 'assistant.card.vacay.fullDay': '全天',
+ 'assistant.card.vacay.halfDay': '半天',
+ 'assistant.card.vacay.compDay': '補休',
+ 'assistant.card.vacay.daysLeft': '剩餘 {count} 天',
+ 'assistant.card.vacay.usedDays': '已用 {count} 天',
+ 'assistant.card.vacay.usedOf': '共 {total} 天,已用 {used}',
+ 'assistant.card.vacay.uses': '佔用 {count} 天',
+ 'assistant.card.vacay.givesBack': '退回 {count} 天',
+ 'assistant.card.vacay.over': '超出 {count} 天',
+ 'assistant.card.vacay.compDays': '補休 {count} 天',
+ 'assistant.card.vacay.otherDays': '今年還有 {count} 天休假',
+ 'assistant.card.vacay.allowance': '年假額度',
+ 'assistant.card.vacay.meter': '已用 {used},計畫 {planned},共 {total},剩餘 {left}',
+ 'assistant.card.vacay.weekendBlocked': '週末,在你的計畫中已封鎖',
+ 'assistant.card.vacay.weekend': '週末,計入假期',
+ 'assistant.card.vacay.publicHoliday': '公共假日,無需請假',
+ 'assistant.card.vacay.companyHoliday': '公司假日,無需請假',
+ 'assistant.card.vacay.companyHalf': '半天公司假日,還剩半天',
+ 'assistant.card.vacay.notInPlan': '不在你的假期計畫中',
+ 'assistant.card.vacay.companyOff': '此計畫已關閉公司假日',
+ 'assistant.card.vacay.twice': '列出兩次,互相抵銷',
+ 'assistant.card.vacay.trip': '本次旅行',
+ 'assistant.card.vacay.tapToSkip': '點按以排除',
+ 'assistant.card.vacay.tapToTake': '點按以包含',
+ 'assistant.card.vacay.open': '在 Vacay 中開啟',
+ 'assistant.step.vacayEntries': '正在讀取你的假期',
+ 'assistant.step.vacayStats': '正在計算剩餘天數',
+ 'assistant.step.vacayHolidays': '正在查詢公共假日',
+ 'assistant.tool.add_vacay_year': '在 Vacay 新增年份',
+ 'assistant.tool.delete_vacay_year': '從 Vacay 刪除年份',
+ 'assistant.tool.update_vacay_plan': '更改 Vacay 設定',
+ 'assistant.tool.set_vacay_color': '更改你在 Vacay 中的顏色',
+ 'assistant.tool.add_holiday_calendar': '新增假日行事曆',
+ 'assistant.tool.delete_holiday_calendar': '刪除假日行事曆',
+};
export default assistantVacay;
diff --git a/shared/src/i18n/zh/assistantVacay.ts b/shared/src/i18n/zh/assistantVacay.ts
index 0a509629a..471477dfd 100644
--- a/shared/src/i18n/zh/assistantVacay.ts
+++ b/shared/src/i18n/zh/assistantVacay.ts
@@ -1,4 +1,45 @@
import type { TranslationStrings } from '../types';
-const assistantVacay: TranslationStrings = {};
+const assistantVacay: TranslationStrings = {
+ 'assistant.change.verb.addVacationDay': '请一天假',
+ 'assistant.change.verb.removeVacationDay': '取消一天假',
+ 'assistant.change.verb.changeVacationDay': '更改一天假',
+ 'assistant.change.verb.setCompanyHoliday': '设置公司假日',
+ 'assistant.change.verb.setVacationAllowance': '更改年假额度',
+ 'assistant.card.vacay.label': '假期日历',
+ 'assistant.card.vacay.fullDay': '全天',
+ 'assistant.card.vacay.halfDay': '半天',
+ 'assistant.card.vacay.compDay': '调休',
+ 'assistant.card.vacay.daysLeft': '剩余 {count} 天',
+ 'assistant.card.vacay.usedDays': '已用 {count} 天',
+ 'assistant.card.vacay.usedOf': '共 {total} 天,已用 {used}',
+ 'assistant.card.vacay.uses': '占用 {count} 天',
+ 'assistant.card.vacay.givesBack': '退回 {count} 天',
+ 'assistant.card.vacay.over': '超出 {count} 天',
+ 'assistant.card.vacay.compDays': '调休 {count} 天',
+ 'assistant.card.vacay.otherDays': '今年还有 {count} 天休假',
+ 'assistant.card.vacay.allowance': '年假额度',
+ 'assistant.card.vacay.meter': '已用 {used},计划 {planned},共 {total},剩余 {left}',
+ 'assistant.card.vacay.weekendBlocked': '周末,在你的计划中已屏蔽',
+ 'assistant.card.vacay.weekend': '周末,计入假期',
+ 'assistant.card.vacay.publicHoliday': '公共假日,无需请假',
+ 'assistant.card.vacay.companyHoliday': '公司假日,无需请假',
+ 'assistant.card.vacay.companyHalf': '半天公司假日,还剩半天',
+ 'assistant.card.vacay.notInPlan': '不在你的假期计划中',
+ 'assistant.card.vacay.companyOff': '此计划已关闭公司假日',
+ 'assistant.card.vacay.twice': '列出两次,相互抵消',
+ 'assistant.card.vacay.trip': '本次旅行',
+ 'assistant.card.vacay.tapToSkip': '点按以排除',
+ 'assistant.card.vacay.tapToTake': '点按以包含',
+ 'assistant.card.vacay.open': '在 Vacay 中打开',
+ 'assistant.step.vacayEntries': '正在读取你的假期',
+ 'assistant.step.vacayStats': '正在计算剩余天数',
+ 'assistant.step.vacayHolidays': '正在查询公共假日',
+ 'assistant.tool.add_vacay_year': '向 Vacay 添加年份',
+ 'assistant.tool.delete_vacay_year': '从 Vacay 删除年份',
+ 'assistant.tool.update_vacay_plan': '更改 Vacay 设置',
+ 'assistant.tool.set_vacay_color': '更改你在 Vacay 中的颜色',
+ 'assistant.tool.add_holiday_calendar': '添加假日日历',
+ 'assistant.tool.delete_holiday_calendar': '删除假日日历',
+};
export default assistantVacay;