feat(assistant): vacation calendar cards with the days left

This commit is contained in:
Maurice
2026-10-22 15:50:55 +02:00
parent da7af8337d
commit c0f75b9a5e
59 changed files with 4783 additions and 70 deletions
@@ -0,0 +1,91 @@
import type { CSSProperties } from 'react';
import { useTranslation } from '../../../../i18n';
import { safeHexColor } from '../../../../utils/safeColor';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import { CardOpenButton } from '../parts/CardOpen';
import { useViewerId } from '../parts/useViewerId';
import VacayBalance from './VacayBalance';
import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel';
import { personColor } from './vacayCellLook';
import { vacayPathOf } from './vacayLook';
import VacayMonthStrip from './VacayMonthStrip';
/**
* What the vacation planner's reads found, as one card: the months with the
* days off, a legend once more than one thing is drawn, the days off outside
* the months shown, the days left per person, and the way into Vacay on that
* year. Several reads in one answer read as one calendar.
*/
const LEGEND = 'mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-content-muted';
const LEGEND_ITEM = 'inline-flex min-w-0 items-center gap-1.5';
const SWATCH = 'size-2 flex-none rounded-full';
const SQUARE = 'size-2 flex-none rounded-sm';
const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning';
const TRIP = 'size-1.5 flex-none rounded-full bg-info';
const OTHER = 'mt-1.5 text-caption text-content-muted';
const FOOT = 'mt-1 flex justify-end';
interface LegendItem {
key: string;
label: string;
className: string;
style?: CSSProperties;
}
/** What the calendar draws, named once it draws more than one thing. */
function Legend({ view }: { view: VacayView }) {
const { t } = useTranslation();
const viewerId = useViewerId();
const people = view.people.filter((person) => view.days.some((day) => day.personId === person.id));
const holiday = view.holidays[0];
const items: LegendItem[] = people.map((person) => ({
key: `person-${person.id}`,
label: person.id === viewerId ? t('assistant.card.common.you') : person.name,
className: SWATCH,
style: { background: personColor(view, person.id) },
}));
if (holiday) {
const color = safeHexColor(holiday.color, 'var(--danger)');
items.push({
key: 'holiday',
label: t('vacay.publicHoliday'),
className: SQUARE,
style: { background: `color-mix(in srgb, ${color} 28%, transparent)` },
});
}
if (view.company.length > 0) items.push({ key: 'company', label: t('vacay.companyHoliday'), className: COMPANY });
if (view.tripDates.size > 0) items.push({ key: 'trip', label: t('assistant.card.vacay.trip'), className: TRIP });
if (items.length < 2) return null;
return (
<ul className={LEGEND}>
{items.map((item) => (
<li key={item.key} className={LEGEND_ITEM}>
<span aria-hidden="true" className={item.className} style={item.style} />
<bdi className="truncate">{item.label}</bdi>
</li>
))}
</ul>
);
}
export default function VacayAnswerBlock({ cards }: { cards: readonly CardEntry[] }) {
const { t } = useTranslation();
const own = cards.flatMap((entry) => (entry.card.kind === 'vacay' ? [entry.card] : []));
const view = mergeVacayCards(own);
const lines = balanceLines(view, []);
const first = own[0] ?? null;
return (
<CardFrame label={t('assistant.card.vacay.label')}>
{view.months.length > 0 && <VacayMonthStrip view={view} placed={[]} labelOf={() => ''} />}
<Legend view={view} />
{view.otherDays > 0 && <p className={OTHER}>{t('assistant.card.vacay.otherDays', { count: view.otherDays })}</p>}
<VacayBalance lines={lines} view={view} mode="read" />
<div className={FOOT}>
<CardOpenButton target={{ kind: 'page', path: vacayPathOf(first) }} labelKey="assistant.card.vacay.open" />
</div>
</CardFrame>
);
}
@@ -0,0 +1,184 @@
import { ArrowRight, CalendarDays, TriangleAlert } from 'lucide-react';
import type { CSSProperties, ReactNode } from 'react';
import { useTranslation } from '../../../../i18n';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { Tooltip } from '../../../shared/Tooltip';
import { fmtDays } from '../../../Vacay/vacayFormat';
import { useViewerId } from '../parts/useViewerId';
import { type BalanceLine, lineFigures, namesYear, todayStr, type VacayView } from './vacayCardModel';
import { personColor } from './vacayCellLook';
/**
* The days left per person and leave year under the calendar: who, the days
* left before and after the changes that count, a meter of used, planned and
* left days in the person's colour, and what the changes come to (days used
* or given back, past the allowance, a new allowance). A read card shows the
* days left as they are.
*/
const ROW = 'mt-2 first:mt-1.5';
const HEAD = 'flex min-w-0 items-center gap-2';
const DOT = 'size-2 flex-none rounded-full';
const NAME = 'min-w-0 flex-1 text-body text-content';
const FIGURES = 'flex flex-none items-baseline gap-1 tabular-nums';
const FIGURE = 'text-body font-semibold text-content';
const CAPTION = 'text-caption text-content-muted';
const METER = 'mt-1 flex h-1.5 gap-0.5 overflow-hidden rounded-full';
const PILLS = 'mt-1 flex flex-wrap items-center gap-1';
const ARROW = 'flex-none self-center text-content-faint rtl:-scale-x-100';
type Mode = 'read' | 'change';
interface Piece {
key: string;
grow: number;
style: CSSProperties;
label: string;
className?: string;
}
/** The pieces of a person's meter: used, given back, planned, past the allowance, left. */
function meterPieces(line: BalanceLine, color: string, t: (key: string, params?: Record<string, number>) => string) {
const total = line.totalAfter ?? line.total;
const planned = Math.max(0, line.delta);
const given = Math.max(0, -line.delta);
const over = Math.max(0, line.used + line.delta - total);
const left = Math.max(0, total - line.used - line.delta);
const hatch = (tone: string) => `repeating-linear-gradient(45deg, ${tone} 0 2px, transparent 2px 4px)`;
const pieces: Piece[] = [
{
key: 'used',
grow: line.used - given,
style: { background: `color-mix(in srgb, ${color} 78%, transparent)` },
label: t('assistant.card.vacay.usedDays', { count: line.used - given }),
},
{
key: 'given',
grow: given,
style: {},
label: t('assistant.card.vacay.givesBack', { count: given }),
className: 'rounded-full outline-dashed outline-1 outline-offset-[-1px] outline-edge',
},
{
key: 'planned',
grow: planned - over,
style: { background: hatch(`color-mix(in srgb, ${color} 78%, transparent)`) },
label: t('assistant.card.vacay.uses', { count: planned - over }),
},
{
key: 'over',
grow: over,
style: { background: `${hatch('var(--danger)')}, var(--danger-soft)` },
label: t('assistant.card.vacay.over', { count: over }),
},
{
key: 'left',
grow: left,
style: { background: `color-mix(in srgb, ${color} 16%, transparent)` },
label: t('assistant.card.vacay.daysLeft', { count: left }),
},
];
return pieces.filter((piece) => piece.grow > 0);
}
function Line({ line, view, mode, withYear }: { line: BalanceLine; view: VacayView; mode: Mode; withYear: boolean }) {
const { t } = useTranslation();
const viewerId = useViewerId();
const person = view.people.find((candidate) => candidate.id === line.personId);
const color = personColor(view, line.personId);
const { left, leftAfter, over } = lineFigures(line);
const changes = line.delta !== 0 || line.totalAfter !== null;
const total = line.totalAfter ?? line.total;
const name = line.personId === viewerId ? t('assistant.card.common.you') : (person?.name ?? `#${line.personId}`);
const usedOf = t('assistant.card.vacay.usedOf', { used: fmtDays(line.used), total: fmtDays(total) });
const meter = t('assistant.card.vacay.meter', {
used: fmtDays(line.used),
planned: fmtDays(Math.max(0, line.delta)),
left: fmtDays(Math.max(0, leftAfter)),
total: fmtDays(total),
});
return (
<li className={ROW}>
<div className={HEAD}>
<span aria-hidden="true" className={DOT} style={{ background: color }} />
<bdi className={NAME}>{name}</bdi>
{withYear && <WhiteBadge>{line.year}</WhiteBadge>}
<span className={FIGURES}>
{changes && (
<>
<span className={FIGURE}>{fmtDays(left)}</span>
<ArrowRight size={12} aria-hidden="true" className={ARROW} />
</>
)}
{mode === 'read' && !changes && <span className={CAPTION}>{usedOf}</span>}
<span className={changes ? FIGURE : CAPTION}>
{t('assistant.card.vacay.daysLeft', { count: Math.max(0, changes ? leftAfter : left) })}
</span>
</span>
</div>
<div role="img" aria-label={meter} className={METER}>
{meterPieces(line, color, t).map((piece) => (
<Tooltip key={piece.key} label={piece.label} placement="top">
<span className={piece.className} style={{ ...piece.style, flexGrow: piece.grow }} />
</Tooltip>
))}
</div>
<BalanceFacts line={line} mode={mode} over={over} />
</li>
);
}
/** What the changes come to, in pills; a read card names the comp days only. */
function BalanceFacts({ line, mode, over }: { line: BalanceLine; mode: Mode; over: number }) {
const { t } = useTranslation();
const pills: ReactNode[] = [];
if (line.delta > 0) {
pills.push(<SoftPill key="uses">{t('assistant.card.vacay.uses', { count: line.delta })}</SoftPill>);
}
if (line.delta < 0) {
pills.push(<SoftPill key="given">{t('assistant.card.vacay.givesBack', { count: -line.delta })}</SoftPill>);
}
if (over > 0) {
pills.push(
<SoftPill key="over" tone="danger" icon={<TriangleAlert size={10} aria-hidden="true" />}>
{t('assistant.card.vacay.over', { count: over })}
</SoftPill>
);
}
if (line.totalAfter !== null) {
// The allowance alone, as the user set it: the totals also hold what was carried over.
pills.push(
<SoftPill key="allowance" icon={<CalendarDays size={10} aria-hidden="true" />}>
{t('assistant.card.vacay.allowance')}
<bdi className="text-content-faint line-through">{fmtDays(line.total - line.carried)}</bdi>
<ArrowRight size={9} aria-hidden="true" className={ARROW} />
<bdi>{fmtDays(line.totalAfter - line.carried)}</bdi>
</SoftPill>
);
}
if (mode === 'read' && line.comp > 0) {
pills.push(<SoftPill key="comp">{t('assistant.card.vacay.compDays', { count: line.comp })}</SoftPill>);
}
return pills.length > 0 ? <div className={PILLS}>{pills}</div> : null;
}
export default function VacayBalance({
lines,
view,
mode,
}: {
lines: readonly BalanceLine[];
view: VacayView;
mode: Mode;
}) {
if (lines.length === 0) return null;
const withYear = namesYear(lines, todayStr());
return (
<ul>
{lines.map((line) => (
<Line key={`${line.personId}:${line.year}`} line={line} view={view} mode={mode} withYear={withYear} />
))}
</ul>
);
}
@@ -0,0 +1,215 @@
/**
* FE-AI-VACAY-010 to FE-AI-VACAY-015
*
* The vacation card as the chat draws it: an answer as a month calendar with
* the days left and the way into Vacay, a change card's vacation rows under
* one calendar whose held days are taken in or left out with a tap, the days
* left following the choice, each row's facts, a decided card, and the phone,
* where one month fills the row.
*/
import type { AssistantApplied, AssistantChangeVerbKey, VacayCard, VacayCardProposal } from '@trek/shared';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import { useAuthStore } from '../../../../store/authStore';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import VacayAnswerBlock from './VacayAnswerBlock';
import VacayRowFacts from './VacayRowFacts';
const TRIP = 4;
const AUTH = useAuthStore.getState();
const INITIAL = useAssistantStore.getState();
const proposal = (over: Partial<VacayCardProposal> = {}): VacayCardProposal => ({
date: '2026-10-27',
personId: 7,
kind: 'vacation',
fraction: 1,
outcome: 'add',
before: null,
periodYear: 2026,
notes: [],
deltas: [{ personId: 7, year: 2026, days: 1 }],
...over,
});
const card = (over: Partial<VacayCard> = {}): VacayCard => ({
kind: 'vacay',
months: [{ year: 2026, month: 10 }],
weekStart: 1,
weekendDays: [0, 6],
people: [
{ id: 7, name: 'Ana', avatarKey: null, color: '#6366f1' },
{ id: 8, name: 'Ben', avatarKey: null, color: '#ec4899' },
],
days: [
{ date: '2026-10-26', personId: 7, fraction: 1, kind: 'vacation' },
{ date: '2026-10-29', personId: 8, fraction: 1, kind: 'vacation' },
],
company: [],
holidays: [{ date: '2026-10-03', name: 'Unity Day', color: null }],
tripDates: ['2026-10-28'],
proposal: null,
balances: [
{
personId: 7,
year: 2026,
total: 30,
used: 29,
comp: 0,
totalAfter: null,
windowStart: '2026-01-01',
windowEnd: '2027-01-01',
},
],
otherDays: 0,
...over,
});
function row(
id: string,
verbKey: AssistantChangeVerbKey,
held: VacayCardProposal,
state: ChangeItem['state'] = 'pending'
): ChangeItem {
const applied: AssistantApplied | null =
state === 'applied' ? { toolCallId: id, entity: 'vacay', ids: [], dayId: null, undoUntil: null } : null;
return {
toolCallId: id,
preview: { toolCallId: id, verbKey, subject: held.date, rows: [], dayId: null, placeIds: [], undoable: false },
applied,
state,
card: card({ proposal: held }),
};
}
function inView(ui: ReactElement, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
const decide = vi.fn(async () => {});
beforeEach(() => {
useAuthStore.setState({ user: buildUser({ id: 7 }) });
useAssistantStore.setState({
decide,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } },
});
});
afterEach(() => {
useAuthStore.setState(AUTH, true);
useAssistantStore.setState(INITIAL, true);
vi.clearAllMocks();
});
describe('the vacation card', () => {
it('FE-AI-VACAY-010: an answer shows its month, a legend, the days left and the way into Vacay on its year', () => {
const onOpen = vi.fn();
inView(<VacayAnswerBlock cards={[{ toolCallId: 'r1', card: card({ otherDays: 2 }) }]} />, { onOpen });
expect(screen.getByRole('group', { name: 'October 2026' })).toBeInTheDocument();
expect(screen.getAllByText('You').length).toBeGreaterThan(1);
for (const label of ['Ben', 'Public Holiday', 'This trip', '2 more days off this year', '29 of 30 used']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(screen.getByText('1 day left')).toBeInTheDocument();
expect(screen.getByRole('img', { name: '29 used, 0 planned, 1 left of 30' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Open in Vacay/ }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/vacay?year=2026' });
});
it('FE-AI-VACAY-011: held days sit on the calendar; a free one starts left out and a tap takes it in', () => {
inView(
<AssistantChangeCard
items={[
row('c1', 'addVacationDay', proposal()),
row('c2', 'removeVacationDay', proposal({ date: '2026-10-26', outcome: 'remove', deltas: [] })),
row('c3', 'addVacationDay', proposal({ date: '2026-10-03', notes: ['publicHoliday'] })),
]}
/>
);
const taken = screen.getByRole('button', { name: 'Tue, Oct 27: Take a day off' });
const free = screen.getByRole('button', { name: 'Sat, Oct 3: Take a day off' });
expect(taken).toHaveAttribute('aria-pressed', 'true');
expect(free).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Mon, Oct 26: Cancel a day off' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply 2' })).toBeEnabled();
expect(screen.getByText('Public holiday, no leave needed')).toBeInTheDocument();
fireEvent.click(free);
expect(screen.getByRole('button', { name: 'Sat, Oct 3: Take a day off' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Apply 3' }));
expect(decide).toHaveBeenCalledWith(TRIP, [
{ toolCallId: 'c1', approved: true },
{ toolCallId: 'c2', approved: true },
{ toolCallId: 'c3', approved: true },
]);
});
it('FE-AI-VACAY-012: the days left follow the choice, up to a warning past the allowance', () => {
inView(
<AssistantChangeCard
items={[row('c1', 'addVacationDay', proposal()), row('c2', 'addVacationDay', proposal({ date: '2026-10-28' }))]}
/>
);
expect(screen.getByText('Uses 2 days')).toBeInTheDocument();
expect(screen.getByText('Over by 1 day')).toBeInTheDocument();
expect(screen.getByText('0 days left')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Wed, Oct 28: Take a day off' }));
expect(screen.queryByText('Over by 1 day')).toBeNull();
expect(screen.getByText('Uses 1 day')).toBeInTheDocument();
});
it("FE-AI-VACAY-013: a row says how much of the day, whose day when it is not the user's, and a new allowance", () => {
const halfForBen = proposal({
personId: 8,
outcome: 'convert',
fraction: 0.5,
before: { fraction: 1, kind: 'vacation' },
notes: ['weekend'],
});
const item = row('c1', 'changeVacationDay', halfForBen);
const view = inView(<VacayRowFacts card={card({ proposal: halfForBen })} item={item} />);
expect(screen.getByText('Full day')).toHaveClass('line-through');
expect(screen.getByText('Half day')).toBeInTheDocument();
expect(screen.getByText('Ben')).toBeInTheDocument();
expect(screen.getByText('Weekend day, counts as leave')).toBeInTheDocument();
view.unmount();
// A company holiday while they are off in the plan: the tool writes it anyway, the row warns.
const off = proposal({ personId: null, kind: 'company', deltas: [], notes: ['companyOff'] });
const company = inView(<VacayRowFacts card={card({ proposal: off })} item={item} />);
expect(screen.getByText('Company holidays are turned off in this plan')).toBeInTheDocument();
company.unmount();
// The allowance alone: the totals 30 and 33 both hold the 2 days carried over.
const balance = { ...card().balances[0], carried: 2, totalAfter: 33 };
const allowance = card({ months: [], balances: [balance] });
inView(<VacayRowFacts card={allowance} item={item} />);
expect(screen.getByText('28')).toHaveClass('line-through');
expect(screen.getByText('31')).toBeInTheDocument();
});
it('FE-AI-VACAY-014: a decided card shows what was applied, without taps, and its receipt opens Vacay', () => {
const onOpen = vi.fn();
inView(<AssistantChangeCard items={[row('c1', 'addVacationDay', proposal(), 'applied')]} />, { onOpen });
expect(screen.queryByRole('button', { name: /Take a day off/ })).toBeNull();
expect(screen.getByText('0 days left')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open in Vacay' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/vacay?year=2026' });
});
it('FE-AI-VACAY-015: on the phone one month fills the row and a cell takes a finger', () => {
const { container } = inView(<AssistantChangeCard items={[row('c1', 'addVacationDay', proposal())]} />, {
variant: 'fullscreen',
});
expect(container.querySelector('li.w-full')).not.toBeNull();
expect(screen.getByRole('button', { name: 'Tue, Oct 27: Take a day off' })).toHaveClass('size-9');
});
});
@@ -0,0 +1,85 @@
import { TriangleAlert } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import { SoftPill } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext';
import { verbLookOf } from '../cardLooks';
import VacayBalance from './VacayBalance';
import {
allowancesOf,
balanceLines,
mergeVacayCards,
type PlacedProposal,
placedProposals,
vacayCardsOf,
} from './vacayCardModel';
import VacayMonthStrip from './VacayMonthStrip';
/**
* The calendar over a change card's vacation rows: the months of the held
* days with what is logged there, each held day on its cell (tap one to take
* it in or leave it out), and the days left before and after what is chosen.
* Once the changes are decided it shows what was applied.
*/
const GROUP = 'mb-1 rounded-lg bg-surface-secondary px-2.5 pb-2 pt-2';
const PILLS = 'mt-1.5 flex flex-wrap items-center gap-1';
/** A held day's date as the change rows write it. */
function dateLabel(locale: string, date: string): string {
const value = new Date(`${date}T00:00:00`);
return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(value);
}
export default function VacayChangeGroup({
items,
excluded,
onToggle,
place,
}: {
items: readonly ChangeItem[];
excluded: ReadonlySet<string>;
onToggle(id: string): void;
place: 'head' | 'foot';
}) {
const { t, locale } = useTranslation();
const { tripId } = useAssistantView();
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
if (place !== 'head') return null;
const view = mergeVacayCards(vacayCardsOf(items).map((entry) => entry.card));
const placed = placedProposals(items, excluded);
const lines = balanceLines(view, placed, allowancesOf(items, excluded));
const waiting = items.some((item) => item.state === 'pending');
const verbOf = (toolCallId: string) => items.find((item) => item.toolCallId === toolCallId)?.preview?.verbKey;
const labelOf = (entry: PlacedProposal) => {
const verb = verbOf(entry.toolCallId);
const date = dateLabel(locale, entry.proposal.date);
return verb ? `${date}: ${t(verbLookOf(verb).labelKey)}` : date;
};
const twice = placed.some((entry) => entry.twice);
return (
<div className={GROUP}>
{view.months.length > 0 && (
<VacayMonthStrip
view={view}
placed={placed}
onToggle={waiting && !isRunning ? onToggle : undefined}
labelOf={labelOf}
/>
)}
{placed.length > 0 && (
<ul className="sr-only">{placed.map((entry) => <li key={entry.toolCallId}>{labelOf(entry)}</li>)}</ul>
)}
{twice && (
<div className={PILLS}>
<SoftPill tone="warning" icon={<TriangleAlert size={10} aria-hidden="true" />}>
{t('assistant.card.vacay.twice')}
</SoftPill>
</div>
)}
<VacayBalance lines={lines} view={view} mode="change" />
</div>
);
}
@@ -0,0 +1,124 @@
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { Tooltip } from '../../../shared/Tooltip';
import {
type CardMonth,
type CellSize,
type DayCell,
monthCells,
type PlacedProposal,
type VacayView,
} from './vacayCardModel';
import { cellLook } from './vacayCellLook';
/**
* One month of the vacation card, drawn the way the planner draws it: the
* weekdays in the user's language, a person's days in their colour, the
* company's and the public holidays, the trip's days dotted. A held day sits
* on its cell; while it waits, the cell is a button that takes it in or
* leaves it out, and its tooltip says which.
*/
const SIZE: Record<CellSize, string> = { compact: 'size-6', roomy: 'size-7', phone: 'size-9' };
const GRID: Record<CellSize, string> = {
compact: 'grid grid-cols-7 gap-0.5',
roomy: 'grid grid-cols-7 gap-1',
phone: 'grid grid-cols-7 gap-1.5',
};
const TITLE = 'mb-1.5 ps-0.5 text-caption font-semibold capitalize text-content';
const WEEKDAY = 'grid h-5 place-items-center text-caption text-content-faint';
const TRIP_DOT = 'absolute end-0.5 top-0.5 size-1 rounded-full bg-info';
const HALF_DOT = 'absolute bottom-0.5 end-0.5 size-1 rounded-full bg-warning';
interface MonthProps {
view: VacayView;
month: CardMonth;
placed: readonly PlacedProposal[];
today: string;
size: CellSize;
/** Takes a held day in or leaves it out; absent once the card is decided or while a turn runs. */
onToggle?: (toolCallId: string) => void;
/** A held day in words: its date and what it does. */
labelOf: (entry: PlacedProposal) => string;
}
/** The narrow weekday names from the week's first day on; 7 January 2024 was a Sunday. */
function weekdayNames(locale: string, weekStart: number): string[] {
const format = new Intl.DateTimeFormat(locale, { weekday: 'narrow' });
return Array.from({ length: 7 }, (_, column) => format.format(new Date(2024, 0, 7 + ((column + weekStart) % 7))));
}
/** "October 2026" in the user's language. */
function monthTitle(locale: string, year: number, month: number): string {
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(year, month - 1));
}
function Cell({
view,
cell,
size,
onToggle,
labelOf,
}: Pick<MonthProps, 'view' | 'size' | 'onToggle' | 'labelOf'> & { cell: DayCell }) {
const { t } = useTranslation();
const look = cellLook(view, cell);
const className = [look.className, SIZE[size]].join(' ');
const marks = (
<>
{cell.trip && <span aria-hidden="true" className={TRIP_DOT} />}
{look.half && <span aria-hidden="true" className={HALF_DOT} />}
</>
);
const entry = look.proposal;
if (entry && onToggle && (entry.state === 'pending' || entry.state === 'excluded')) {
const label = labelOf(entry);
const hint = t(entry.state === 'pending' ? 'assistant.card.vacay.tapToSkip' : 'assistant.card.vacay.tapToTake');
return (
<Tooltip label={`${label}. ${hint}`} placement="top">
<button
type="button"
aria-pressed={entry.state === 'pending'}
aria-label={label}
onClick={() => onToggle(entry.toolCallId)}
className={className}
style={look.style}
>
{cell.day}
{marks}
</button>
</Tooltip>
);
}
const body = (
<span className={className} style={look.style}>
{cell.day}
{marks}
</span>
);
return cell.holiday ? <Tooltip label={cell.holiday.name} placement="top">{body}</Tooltip> : body;
}
export default function VacayMiniMonth({ view, month, placed, today, size, onToggle, labelOf }: MonthProps) {
const { locale } = useTranslation();
const title = useMemo(() => monthTitle(locale, month.year, month.month), [locale, month.year, month.month]);
const weekdays = useMemo(() => weekdayNames(locale, view.weekStart), [locale, view.weekStart]);
const cells = monthCells(view, month, placed, today);
return (
<div role="group" aria-label={title}>
<p className={TITLE}>{title}</p>
<div className={GRID[size]}>
{weekdays.map((name, column) => (
<span key={`weekday-${column}`} aria-hidden="true" className={WEEKDAY}>{name}</span>
))}
{cells.map((cell, index) =>
cell ? (
<Cell key={cell.date} view={view} cell={cell} size={size} onToggle={onToggle} labelOf={labelOf} />
) : (
<span key={`lead-${index}`} aria-hidden="true" />
)
)}
</div>
</div>
);
}
@@ -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 && (
<div className={PAGER}>
{PAGES.map(({ towards, Icon, edge }) => (
// For the pointer only, as over the other strips: the cells are reachable by Tab.
<button
key={edge}
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade[edge]}
onClick={() => page(towards)}
className={PAGE_ARROW}
>
<Icon size={15} className="rtl:-scale-x-100" />
</button>
))}
</div>
)}
<ul
ref={listRef}
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className={STRIP}
>
{view.months.map((month) => (
<li key={monthKey(month)} className={variant === 'fullscreen' ? PHONE_MONTH : WINDOW_MONTH}>
<VacayMiniMonth
view={view}
month={month}
placed={placed}
today={today}
size={size}
onToggle={onToggle}
labelOf={labelOf}
/>
</li>
))}
</ul>
</>
);
}
@@ -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<VacayProposalNote, { labelKey: string; tone: PillTone }> = {
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<VacayCardProposal, 'kind' | 'fraction'>): (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 <FactChange icon={CalendarDays} before={before} after={fmtDays(balance.totalAfter - carried)} />;
}
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 (
<>
<FactChange icon={size.Icon} before={before} after={t(size.labelKey)} />
{person && (
<SoftPill icon={<span aria-hidden="true" className={DOT} style={{ background: dotColor }} />}>
<bdi className="truncate">{person.name}</bdi>
</SoftPill>
)}
{proposal.notes.map((note) => (
<SoftPill key={note} tone={NOTE_LOOK[note].tone}>{t(NOTE_LOOK[note].labelKey)}</SoftPill>
))}
</>
);
}
@@ -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> = {}): 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> = {}): 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<string>()) => {
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);
});
});
@@ -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<string>;
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<T>(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<string>): 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<string>): 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<string, number>();
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<string, PlacedProposal[]>();
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<string, number> = 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<string>): Map<string, number> {
const found = new Map<string, number>();
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<DayCell | null> {
const lead = monthLead(month.year, month.month - 1, view.weekStart);
const length = new Date(month.year, month.month, 0).getDate();
const cells: Array<DayCell | null> = 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());
}
@@ -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<ProposalLook, string> = {
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,
};
}
@@ -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'>;
@@ -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<K> };
@@ -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
+2 -1
View File
@@ -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,
@@ -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')}`
}
+11
View File
@@ -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(<VacayPage />, { initialEntries: ['/vacay?year=2027'] });
expect(mockLoadAll).toHaveBeenCalledWith(2027);
unmount();
mockLoadAll.mockClear();
render(<VacayPage />, { initialEntries: ['/vacay?year=next'] });
expect(mockLoadAll).toHaveBeenCalledWith(undefined);
});
});
+11 -1
View File
@@ -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<string, readonly VacayReload[]> = {
'vacay:update': ['plan'],
'vacay:settings': ['plan', 'all'],
@@ -40,8 +47,11 @@ export function useVacay() {
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(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)
+14 -1
View File
@@ -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);
});
});
+9 -6
View File
@@ -208,7 +208,8 @@ interface VacayState {
declineInvite: (planId: number) => Promise<void>
cancelInvite: (userId: number) => Promise<void>
dissolve: () => Promise<void>
loadYears: () => Promise<void>
/** Opens `preferred` when the plan has that year (a link from the assistant), else as before. */
loadYears: (preferred?: number) => Promise<void>
addYear: (year: number) => Promise<void>
removeYear: (year: number) => Promise<void>
loadEntries: (year?: number) => Promise<void>
@@ -228,7 +229,7 @@ interface VacayState {
setShareHidden: (shareId: number, hidden: boolean) => Promise<void>
loadYearSettings: () => Promise<void>
updateYearSettings: (data: VacayYearSettingsRequest) => Promise<void>
loadAll: () => Promise<void>
loadAll: (preferredYear?: number) => Promise<void>
}
export const useVacayStore = create<VacayState>((set, get) => ({
@@ -305,16 +306,18 @@ export const useVacayStore = create<VacayState>((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<VacayState>((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)
+15
View File
@@ -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')}`;
}
@@ -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', () => {
+57 -3
View File
@@ -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<VacayLoaded> = {
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<VacayLoaded> = {
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<VacayStatsLoaded> = {
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.',
],
};
@@ -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),
};
},
};
@@ -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<string, Outcome>([
['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: [] } };
}
@@ -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<VacayCardProposal['outcome'], AssistantChangeVerbKey>;
/** 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,
};
},
};
@@ -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<T extends { date: string }>(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<number, VacayPerson>,
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<T extends { personId: number }>(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<number, VacayPerson> | null,
entries: readonly VacayEntryRow[],
stats: readonly VacayStatRow[],
): Map<number, VacayPerson> {
const known = new Map<number, VacayPerson>();
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;
}
@@ -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<VacayHolidayRow[]> {
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<VacayLoaded | null> {
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<VacayStatsLoaded | null> {
const year = yearArg(args);
if (year === null) return null;
const stats = statsOf(await read('get_vacay_stats', { year }));
return stats ? { stats } : null;
}
@@ -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<number, VacayPerson>;
/** 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<number, VacayPerson>();
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();
}
@@ -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<string, unknown> = {}) => ({
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<string, unknown>) {
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<string, unknown> = CALENDAR_YEAR, args: Record<string, unknown> = {}) {
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<string, unknown>, 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<string, unknown>, 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<string, unknown> = {}, 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<string, unknown>, 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();
});
});
@@ -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');
});
});
+124 -3
View File
@@ -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<typeof vacayCardSchema>;
export type VacayCardPerson = z.infer<typeof vacayCardPersonSchema>;
export type VacayCardDay = z.infer<typeof vacayCardDaySchema>;
export type VacayCardHoliday = z.infer<typeof vacayCardHolidaySchema>;
export type VacayCardProposal = z.infer<typeof vacayCardProposalSchema>;
export type VacayCardBalance = z.infer<typeof vacayCardBalanceSchema>;
export type VacayProposalNote = (typeof VACAY_PROPOSAL_NOTES)[number];
export type VacayProposalOutcome = (typeof VACAY_PROPOSAL_OUTCOMES)[number];
export type VacayDayKind = (typeof VACAY_DAY_KINDS)[number];
+10 -2
View File
@@ -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;
+77 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+56 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+42 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+42 -1
View File
@@ -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;
+42 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+63 -1
View File
@@ -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;
+63 -1
View File
@@ -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;
+56 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+42 -1
View File
@@ -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;
+49 -1
View File
@@ -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;
+63 -1
View File
@@ -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;
+42 -1
View File
@@ -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;
+42 -1
View File
@@ -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;
+42 -1
View File
@@ -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;