mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(assistant): vacation calendar cards with the days left
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user