diff --git a/client/src/components/Vacay/VacayCalendar.test.tsx b/client/src/components/Vacay/VacayCalendar.test.tsx
index aa5638973..30d5a8575 100644
--- a/client/src/components/Vacay/VacayCalendar.test.tsx
+++ b/client/src/components/Vacay/VacayCalendar.test.tsx
@@ -7,8 +7,8 @@ import { useVacayStore } from '../../store/vacayStore'
import VacayCalendar from './VacayCalendar'
vi.mock('./VacayMonthCard', () => ({
- default: ({ month, onCellClick }: any) => (
-
+ default: ({ year, month, onCellClick }: any) => (
+
@@ -16,6 +16,8 @@ vi.mock('./VacayMonthCard', () => ({
),
}))
+const fiscalJuly = { year_type: 'fiscal' as const, year_start_month: 7, year_start_day: 1, hire_date: null }
+
const basePlan = {
id: 1,
holidays_enabled: false,
@@ -47,6 +49,51 @@ describe('VacayCalendar', () => {
expect(screen.getAllByTestId(/^month-card-/)).toHaveLength(12)
})
+ it('FE-COMP-VACAYCALENDAR-001a: renders January to December of the selected year by default', () => {
+ seedStore(useVacayStore, {
+ selectedYear: 2025,
+ entries: [],
+ companyHolidays: [],
+ holidays: {},
+ plan: basePlan,
+ users: [],
+ selectedUserId: null,
+ })
+
+ render()
+
+ const cards = screen.getAllByTestId(/^month-card-/)
+ expect(cards[0]).toHaveAttribute('data-testid', 'month-card-0')
+ expect(cards[0]).toHaveAttribute('data-year', '2025')
+ expect(cards[11]).toHaveAttribute('data-testid', 'month-card-11')
+ expect(cards[11]).toHaveAttribute('data-year', '2025')
+ })
+
+ it('FE-COMP-VACAYCALENDAR-001b: rolls the grid over the calendar year for a fiscal window (#737)', () => {
+ seedStore(useVacayStore, {
+ selectedYear: 2026,
+ entries: [],
+ companyHolidays: [],
+ holidays: {},
+ plan: basePlan,
+ users: [],
+ selectedUserId: null,
+ yearSettings: fiscalJuly,
+ })
+
+ render()
+
+ const cards = screen.getAllByTestId(/^month-card-/)
+ expect(cards).toHaveLength(12)
+ // Jul 2026 first, Jan 2027 in the middle, Jun 2027 last.
+ expect(cards[0]).toHaveAttribute('data-testid', 'month-card-6')
+ expect(cards[0]).toHaveAttribute('data-year', '2026')
+ expect(cards[6]).toHaveAttribute('data-testid', 'month-card-0')
+ expect(cards[6]).toHaveAttribute('data-year', '2027')
+ expect(cards[11]).toHaveAttribute('data-testid', 'month-card-5')
+ expect(cards[11]).toHaveAttribute('data-year', '2027')
+ })
+
it('FE-COMP-VACAYCALENDAR-002: shows vacation mode button by default with username', () => {
seedStore(useVacayStore, {
selectedYear: 2025,
@@ -97,10 +144,10 @@ describe('VacayCalendar', () => {
render()
- // Vacation + half-day buttons stay; only the company button is hidden.
+ // Vacation + the comp and half-day modifiers stay; only the company button is hidden.
const buttons = screen.getAllByRole('button')
const toolbarButtons = buttons.filter(b => !b.textContent?.startsWith('click-'))
- expect(toolbarButtons).toHaveLength(2)
+ expect(toolbarButtons).toHaveLength(3)
})
it('FE-COMP-VACAYCALENDAR-005: switching to company mode highlights company button', async () => {
@@ -149,7 +196,7 @@ describe('VacayCalendar', () => {
// Click the first month card cell button (month 0 → date '2025-01-01')
await user.click(screen.getByText('click-0'))
- expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 1)
+ expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 1, 'vacation')
})
it('FE-COMP-VACAYCALENDAR-006b: half-day mode logs the day as 0.5', async () => {
@@ -169,13 +216,58 @@ describe('VacayCalendar', () => {
render()
- // toolbarButtons[0] = vacation, [1] = half day
- const buttons = screen.getAllByRole('button')
- const toolbarButtons = buttons.filter(b => !b.textContent?.startsWith('click-'))
- await user.click(toolbarButtons[1])
+ // Comp and half-day are both modifiers in the toolbar — pick by name, not position.
+ await user.click(screen.getByRole('button', { name: 'Half day' }))
await user.click(screen.getByText('click-0'))
- expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 0.5)
+ expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 0.5, 'vacation')
+ })
+
+ it('FE-COMP-VACAYCALENDAR-006c: the comp modifier logs the day as a comp/flex day (#1074)', async () => {
+ const user = userEvent.setup()
+ const toggleEntry = vi.fn().mockResolvedValue(undefined)
+
+ seedStore(useVacayStore, {
+ selectedYear: 2025,
+ entries: [],
+ companyHolidays: [],
+ holidays: {},
+ plan: { ...basePlan, block_weekends: false, company_holidays_enabled: false },
+ users: [],
+ selectedUserId: 42,
+ toggleEntry,
+ })
+
+ render()
+
+ await user.click(screen.getByRole('button', { name: 'Comp / Flex' }))
+ await user.click(screen.getByText('click-0'))
+
+ expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 1, 'comp')
+ })
+
+ it('FE-COMP-VACAYCALENDAR-006d: comp and half-day stack into a half comp day (#1074)', async () => {
+ const user = userEvent.setup()
+ const toggleEntry = vi.fn().mockResolvedValue(undefined)
+
+ seedStore(useVacayStore, {
+ selectedYear: 2025,
+ entries: [],
+ companyHolidays: [],
+ holidays: {},
+ plan: { ...basePlan, block_weekends: false, company_holidays_enabled: false },
+ users: [],
+ selectedUserId: 42,
+ toggleEntry,
+ })
+
+ render()
+
+ await user.click(screen.getByRole('button', { name: 'Comp / Flex' }))
+ await user.click(screen.getByRole('button', { name: 'Half day' }))
+ await user.click(screen.getByText('click-0'))
+
+ expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42, 0.5, 'comp')
})
it('FE-COMP-VACAYCALENDAR-007: cell click on public holiday toggles vacation entry', async () => {
@@ -198,7 +290,7 @@ describe('VacayCalendar', () => {
// Month 0, button emits '2025-01-01' which is a holiday — should still toggle vacation
await user.click(screen.getByText('click-0'))
- expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined, 1)
+ expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined, 1, 'vacation')
})
it('FE-COMP-VACAYCALENDAR-008: cell click in company mode calls toggleCompanyHoliday', async () => {
diff --git a/client/src/components/Vacay/VacayMonthCard.test.tsx b/client/src/components/Vacay/VacayMonthCard.test.tsx
index 2bfcfe931..b32974749 100644
--- a/client/src/components/Vacay/VacayMonthCard.test.tsx
+++ b/client/src/components/Vacay/VacayMonthCard.test.tsx
@@ -150,18 +150,87 @@ describe('VacayMonthCard', () => {
expect(getFirstWeekLabels()).toEqual(['', '', '', '1', '2', '3', '4'])
})
- it('FE-COMP-VACAYMONTHCARD-011: Two vacation entries fill the cell with a split gradient', () => {
+ it('FE-COMP-VACAYMONTHCARD-011: Two vacation entries split the cell into two clipped halves', () => {
const props = {
...baseProps,
entryMap: {
- '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }, { date: '2025-01-15', user_id: 1, person_color: '#f43f5e' }],
+ '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1' }, { date: '2025-01-15', user_id: 2, person_color: '#f43f5e' }],
},
}
render()
const daySpan = screen.getByText('15')
const cell = daySpan.closest('div') as HTMLElement
- // The split gradient is the cell background itself (glass facelift).
- expect(cell.style.background).toContain('linear-gradient')
+ // Each half is its own clipped overlay so it can be solid or hatched on its
+ // own (#1074) — the diagonal is no longer one gradient on the cell itself.
+ // jsdom normalises the hex inside color-mix() to rgb().
+ const halves = [...cell.querySelectorAll('div')].filter(d => d.style.clipPath)
+ expect(halves).toHaveLength(2)
+ expect(halves[0].style.background).toContain('rgb(99, 102, 241)')
+ expect(halves[1].style.background).toContain('rgb(244, 63, 94)')
+ expect(halves.map(h => h.style.background).join()).not.toContain('repeating-linear-gradient')
+ })
+
+ it('FE-COMP-VACAYMONTHCARD-011a: a comp day hatches only its own half of the split (#1074)', () => {
+ const props = {
+ ...baseProps,
+ entryMap: {
+ '2025-01-15': [
+ { date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const },
+ { date: '2025-01-15', user_id: 2, person_color: '#f43f5e' },
+ ],
+ },
+ }
+ render()
+ const cell = screen.getByText('15').closest('div') as HTMLElement
+ const halves = [...cell.querySelectorAll('div')].filter(d => d.style.clipPath)
+ expect(halves[0].style.background).toContain('repeating-linear-gradient')
+ expect(halves[1].style.background).not.toContain('repeating-linear-gradient')
+ })
+
+ it('FE-COMP-VACAYMONTHCARD-011b: a single comp day hatches the whole cell (#1074)', () => {
+ const props = {
+ ...baseProps,
+ entryMap: {
+ '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const }],
+ },
+ }
+ render()
+ const cell = screen.getByText('15').closest('div') as HTMLElement
+ expect(cell.style.background).toContain('repeating-linear-gradient')
+ })
+
+ it('FE-COMP-VACAYMONTHCARD-011c: an all-comp day keeps the white digit and gains a shadow to carry it (#1074)', () => {
+ const props = {
+ ...baseProps,
+ entryMap: {
+ '2025-01-15': [{ date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const }],
+ // A solid vacation fill needs no shadow — the fill itself is the contrast.
+ '2025-01-16': [{ date: '2025-01-16', user_id: 1, person_color: '#6366f1' }],
+ },
+ }
+ render()
+ const comp = screen.getByText('15')
+ const vacation = screen.getByText('16')
+ expect(comp).toHaveStyle({ color: '#fff' })
+ // Only that the shadow is there — its exact strength is a design value.
+ expect(comp.style.textShadow).not.toBe('')
+ expect(vacation).toHaveStyle({ color: '#fff' })
+ expect(vacation.style.textShadow).toBe('')
+ })
+
+ it('FE-COMP-VACAYMONTHCARD-011d: a mixed comp/vacation day needs no shadow — a solid half sits under the digit (#1074)', () => {
+ const props = {
+ ...baseProps,
+ entryMap: {
+ '2025-01-15': [
+ { date: '2025-01-15', user_id: 1, person_color: '#6366f1', kind: 'comp' as const },
+ { date: '2025-01-15', user_id: 2, person_color: '#f43f5e' },
+ ],
+ },
+ }
+ render()
+ expect(screen.getByText('15')).toHaveStyle({ color: '#fff' })
+ expect(screen.getByText('15').style.textShadow).toBe('')
})
it('FE-COMP-VACAYMONTHCARD-012: Four vacation entries render quadrant overlay', () => {
diff --git a/client/src/mobile/screens/vacay/vacayDayModel.ts b/client/src/mobile/screens/vacay/vacayDayModel.ts
index a0f209056..f6275658f 100644
--- a/client/src/mobile/screens/vacay/vacayDayModel.ts
+++ b/client/src/mobile/screens/vacay/vacayDayModel.ts
@@ -8,6 +8,9 @@ export const FALLBACK_PERSON_COLOR = '#6366f1'
export interface DayVisual {
background: string
numColor: string
+ // Set on hatched (comp/flex) cells, where the screen shows through between the
+ // stripes and the digit needs its own contrast (#1074).
+ textShadow?: string
boxShadow?: string
// At least one person logged this day as a half day (#552) — the cell shows a ½ badge.
half?: boolean
@@ -81,15 +84,6 @@ export function hatchTint(color: string): string {
return `repeating-linear-gradient(45deg, ${color} 0 2.5px, transparent 2.5px 5px)`
}
-/** Hard split background when several persons logged the same day. */
-export function splitBackground(colors: string[]): string {
- const n = colors.length
- const stops = colors
- .map((c, i) => `${c} ${Math.round((i * 100) / n)}% ${Math.round(((i + 1) * 100) / n)}%`)
- .join(',')
- return `linear-gradient(105deg,${stops})`
-}
-
/**
* Day-cell color matrix, priority top-down: today ring > company holiday >
* logged persons (pastel, split for several) > public holiday > weekend >
@@ -120,7 +114,13 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
const segments = entries.map(e => ({ color: personTint(e.person_color || FALLBACK_PERSON_COLOR), comp: e.kind === 'comp' }))
const single = segments.length === 1
const background = single ? (segments[0].comp ? hatchTint(segments[0].color) : segments[0].color) : 'transparent'
+ // The pastel fills are theme-independent surfaces, so every logged day keeps
+ // the same hard dark digit. An all-comp day is hatched, and the screen shows
+ // through between its stripes — a light shadow carries the contrast there so
+ // the digit survives dark mode without changing colour (#1074).
+ const allComp = segments.every(s => s.comp)
const visual: DayVisual = { background, numColor: '#101013', segments }
+ if (allComp) visual.textShadow = '0 1px 2px rgba(255,255,255,0.9), 0 0 3px rgba(255,255,255,0.6)'
if (entries.some(e => (e.fraction ?? 1) === 0.5)) visual.half = true
return withSchool(visual)
}
diff --git a/client/tests/unit/mobile/vacay/vacayDayModel.test.ts b/client/tests/unit/mobile/vacay/vacayDayModel.test.ts
index 6383f4fba..d87e39499 100644
--- a/client/tests/unit/mobile/vacay/vacayDayModel.test.ts
+++ b/client/tests/unit/mobile/vacay/vacayDayModel.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { dayVisual, monthLead, personTint, splitBackground, type DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel';
+import { dayVisual, hatchTint, monthLead, personTint, type DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel';
// FE-MOB-VACAY-001 onwards
@@ -44,11 +44,13 @@ describe('vacayDayModel', () => {
// ...but not when company holidays are disabled.
expect(dayVisual('2026-07-15', 3, { ...notToday, companyHolidaysEnabled: false }).numColor).toBe('#101013');
- // Logged person: pastel tint of the person color, hard dark digit.
+ // Logged person: pastel tint of the person color, hard dark digit. One person
+ // fills the cell directly and still reports the segment (#1074).
const logged = ctx({ todayStr: '2026-01-01', entryMap: { '2026-07-15': [entry] } });
expect(dayVisual('2026-07-15', 3, logged)).toEqual({
background: personTint('#EC4899'),
numColor: '#101013',
+ segments: [{ color: personTint('#EC4899'), comp: false }],
});
// Public holiday: calendar color fill.
@@ -66,7 +68,7 @@ describe('vacayDayModel', () => {
});
});
- it('FE-MOB-VACAY-002: splits logged days evenly and offsets month starts by week start', () => {
+ it('FE-MOB-VACAY-002: splits logged days into segments and offsets month starts by week start', () => {
const two = ctx({
todayStr: '2026-01-01',
entryMap: {
@@ -76,10 +78,42 @@ describe('vacayDayModel', () => {
],
},
});
- expect(dayVisual('2026-07-15', 3, two).background).toBe(
- splitBackground([personTint('#EC4899'), personTint('#2FA9A0')]),
- );
- expect(splitBackground(['a', 'b'])).toBe('linear-gradient(105deg,a 0% 50%,b 50% 100%)');
+ // Several persons render as segment overlays (#1074), so the cell itself stays
+ // transparent underneath them and the split lives in `segments`.
+ const shared = dayVisual('2026-07-15', 3, two);
+ expect(shared.background).toBe('transparent');
+ expect(shared.segments).toEqual([
+ { color: personTint('#EC4899'), comp: false },
+ { color: personTint('#2FA9A0'), comp: false },
+ ]);
+
+ // A comp/flex day marks its own segment without touching the other one.
+ const mixed = ctx({
+ todayStr: '2026-01-01',
+ entryMap: {
+ '2026-07-15': [
+ { date: '2026-07-15', user_id: 1, person_color: '#EC4899', kind: 'comp' as const },
+ { date: '2026-07-15', user_id: 2, person_color: '#2FA9A0' },
+ ],
+ },
+ });
+ expect(dayVisual('2026-07-15', 3, mixed).segments).toEqual([
+ { color: personTint('#EC4899'), comp: true },
+ { color: personTint('#2FA9A0'), comp: false },
+ ]);
+
+ // A single comp day hatches the whole cell instead.
+ const soloComp = ctx({
+ todayStr: '2026-01-01',
+ entryMap: { '2026-07-15': [{ date: '2026-07-15', user_id: 1, person_color: '#EC4899', kind: 'comp' as const }] },
+ });
+ expect(dayVisual('2026-07-15', 3, soloComp).background).toBe(hatchTint(personTint('#EC4899')));
+ // The hatch lets the screen through, so the digit keeps its colour and gains a
+ // light shadow to stay readable; a day with a solid segment needs none.
+ expect(dayVisual('2026-07-15', 3, soloComp).numColor).toBe('#101013');
+ expect(dayVisual('2026-07-15', 3, soloComp).textShadow).toBeTruthy();
+ expect(dayVisual('2026-07-15', 3, mixed).numColor).toBe('#101013');
+ expect(dayVisual('2026-07-15', 3, mixed).textShadow).toBeUndefined();
// July 2026 starts on a Wednesday: 2 leading cells Monday-first, 3 Sunday-first.
expect(monthLead(2026, 6, 1)).toBe(2);
diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts
index 064e7f2df..5cc81dcef 100644
--- a/server/tests/e2e/vacay.e2e.test.ts
+++ b/server/tests/e2e/vacay.e2e.test.ts
@@ -83,7 +83,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
.set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ action: 'added' });
- expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'sock-7');
+ expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock-7');
});
it('400 on entries/toggle without a date', async () => {
diff --git a/server/tests/unit/nest/vacay.controller.test.ts b/server/tests/unit/nest/vacay.controller.test.ts
index 44855d120..7336923ac 100644
--- a/server/tests/unit/nest/vacay.controller.test.ts
+++ b/server/tests/unit/nest/vacay.controller.test.ts
@@ -137,13 +137,42 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
it('toggles for the caller', () => {
const toggleEntry = vi.fn().mockReturnValue({ action: 'added' });
expect(makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' });
- expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'sock');
+ expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock');
});
it('forwards the half-day fraction (#552)', () => {
const toggleEntry = vi.fn().mockReturnValue({ action: 'added', fraction: 0.5 });
makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock');
- expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, 'sock');
+ expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, undefined, 'sock');
+ });
+
+ it('forwards the comp/flex leave type (#1074)', () => {
+ const toggleEntry = vi.fn().mockReturnValue({ action: 'added', kind: 'comp' });
+ makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', kind: 'comp' }, 'sock');
+ expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'comp', 'sock');
+ });
+
+ it('GET reads entries over the caller window (#737)', () => {
+ const getEntries = vi.fn().mockReturnValue({ entries: [], companyHolidays: [] });
+ makeController({ ...planBase, getEntries }).entries(user, '2026');
+ expect(getEntries).toHaveBeenCalledWith(10, '2026', 1);
+ });
+ });
+
+ describe('year settings (#737)', () => {
+ it('GET wraps the caller settings', () => {
+ const getYearSettings = vi.fn().mockReturnValue({ year_type: 'calendar' });
+ expect(makeController({ getYearSettings }).yearSettings(user)).toEqual({ settings: { year_type: 'calendar' } });
+ expect(getYearSettings).toHaveBeenCalledWith(1);
+ });
+
+ it('PUT saves for the caller and returns the stored settings', () => {
+ const updateYearSettings = vi.fn().mockReturnValue({ year_type: 'fiscal', year_start_month: 7, year_start_day: 1 });
+ const body = { year_type: 'fiscal' as const, year_start_month: 7, year_start_day: 1 };
+ expect(makeController({ updateYearSettings }).updateYearSettings(user, body)).toEqual({
+ settings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 },
+ });
+ expect(updateYearSettings).toHaveBeenCalledWith(1, body);
});
});