feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737)

Comp/Flex days (#1074) — complete. A second leave type that does not deduct from the vacation entitlement (flextime / overtime offset). New kind column on vacay_entries, excluded from usedDays via a CASE sum; toggleEntry carries kind through service/nest/store with change-detection; desktop and mobile get a comp toolbar toggle next to the half-day one (orthogonal, so a half comp day is free); comp segments render as a diagonal hatch of the person colour, per-segment for split days (Version B). comp_used is surfaced separately in getStats. Translated into all 23 locales.

Configurable vacation year (#737) — backend only. Per-user vacay_user_settings (calendar / fiscal / anniversary), resolveYearWindow, and usedDays / compUsedDays / getStats / carry-over now computed over the leave-year [start,end) window. 'calendar' resolves byte-identically to the old date-prefix behaviour, so nothing changes for existing users and the feature is inert until the settings UI lands. Frontend (settings selector + calendar window rollover), the settings endpoint, window-aware getEntries and holiday loading are still to do — see the PR description.
This commit is contained in:
Maurice
2026-07-24 20:40:15 +02:00
committed by Maurice
parent c61203e607
commit c475e5b04c
38 changed files with 329 additions and 67 deletions
+14 -2
View File
@@ -1,4 +1,4 @@
import { Building2, ChevronLeft, ChevronRight, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
import { Building2, ChevronLeft, ChevronRight, Clock, Eye, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
@@ -261,8 +261,20 @@ export default function MVacay() {
{t('vacay.modeCompany')}
</button>
)}
{/* Divider — the half-day switch modifies the selected person's logging, it isn't a mode. */}
{/* Divider — comp/flex and half-day modify the selected person's logging, they aren't modes. */}
<span className="mx-[1px] h-5 w-px self-center bg-[color:var(--m-shbr)]" aria-hidden />
<button
type="button"
onClick={() => v.setCompDay(c => !c)}
aria-pressed={v.compDay}
aria-label={t('vacay.modeComp')}
title={t('vacay.modeCompHint')}
className={`flex h-9 w-9 flex-none items-center justify-center rounded-full ${
v.compDay ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
<Clock size={16} strokeWidth={2.2} />
</button>
<button
type="button"
onClick={() => v.setHalfDay(h => !h)}
@@ -1,5 +1,5 @@
import { schoolHolidayBand } from '../../../components/Vacay/holidayVisual'
import { dayVisual, localDateStr, monthLead, type DayVisualContext } from './vacayDayModel'
import { dayVisual, hatchTint, localDateStr, monthLead, type DayVisualContext } from './vacayDayModel'
interface MVacayMonthProps {
year: number
@@ -45,7 +45,23 @@ export default function MVacayMonth({
}`}
style={{ background: visual.background, color: visual.numColor, boxShadow: visual.boxShadow }}
>
{day}
{/* 2+ people: equal-width segment overlays, each solid (vacation) or hatched (comp) (#1074). */}
{visual.segments && visual.segments.length > 1 && (
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: 'inherit' }} aria-hidden>
{visual.segments.map((seg, si) => (
<div
key={si}
className="absolute top-0 h-full"
style={{
left: `${(si * 100) / visual.segments!.length}%`,
width: `${100 / visual.segments!.length}%`,
background: seg.comp ? hatchTint(seg.color) : seg.color,
}}
/>
))}
</div>
)}
<span className="relative z-[1]">{day}</span>
{tripDates.has(dateStr) && (
<span
aria-hidden
+5 -3
View File
@@ -42,6 +42,8 @@ export function useMVacay() {
const [mode, setMode] = useState<MVacayMode>('vacation')
// Half-day modifier: when on, taps log the selected person's day as 0.5 (#552).
const [halfDay, setHalfDay] = useState(false)
// Comp/Flex day modifier (#1074): when on, taps log the day as kind='comp' (free).
const [compDay, setCompDay] = useState(false)
const [sheet, setSheet] = useState<MVacaySheet>(null)
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
@@ -155,8 +157,8 @@ export function useMVacay() {
}
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1)
}, [view, mode, halfDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId])
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1, compDay ? 'comp' : 'vacation')
}, [view, mode, halfDay, compDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId])
// Entitlement stepper: never below what is already used this year
// (carried-over days cover the difference when used > entitlement).
@@ -198,7 +200,7 @@ export function useMVacay() {
users, isFused, currentUser,
incomingInvites, acceptInvite, declineInvite,
incomingShares, toggleShareHidden,
view, month, mode, halfDay, setHalfDay, sheet, setSheet, setMode, setMonth,
view, month, mode, halfDay, setHalfDay, compDay, setCompDay, sheet, setSheet, setMode, setMonth,
tripDates, tripDotColor,
blockWeekends, companyHolidaysEnabled, holidaysEnabled, weekStart, weekendDays,
dayCtx, monthNamesShort, monthNameLong,
@@ -11,6 +11,10 @@ export interface DayVisual {
boxShadow?: string
// At least one person logged this day as a half day (#552) — the cell shows a ½ badge.
half?: boolean
// Per-person fill segments for this day (#1074): each carries the person's tint and
// whether they logged a comp/flex day (hatched, kind='comp') vs. a vacation day
// (solid). Set only when someone is off; the cell renders overlays for 2+ people.
segments?: { color: string; comp: boolean }[]
// Colours of the school-holiday calendars covering this day — drawn as a rounded
// accent band under the number, on top of whatever fill the cell already has.
school?: string[]
@@ -72,6 +76,11 @@ export function holidayInk(color: string): string {
return `hsl(${hsl.h} ${s}% 42%)`
}
/** Diagonal hatch of a tint for comp/flex days (#1074), matching the desktop card. */
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
@@ -104,11 +113,14 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
}
const entries = ctx.entryMap[dateStr]
if (entries && entries.length > 0) {
const tints = entries.map(e => personTint(e.person_color || FALLBACK_PERSON_COLOR))
// The fill still shows WHO is off; half days (#552) keep it and add a corner
// dot, so a half day never looks like a two-person split. Only set `half` when
// true so full days keep their exact { background, numColor } shape.
const visual: DayVisual = { background: tints.length === 1 ? tints[0] : splitBackground(tints), numColor: '#101013' }
// The fill still shows WHO is off; half days (#552) keep it and add a corner dot.
// Comp/flex days (#1074) hatch their segment. 1 person fills the cell directly
// (solid or hatched); several render segment overlays, so the background stays
// transparent underneath them.
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'
const visual: DayVisual = { background, numColor: '#101013', segments }
if (entries.some(e => (e.fraction ?? 1) === 0.5)) visual.half = true
return withSchool(visual)
}