feat(vacay): half vacation days (#552) (#1631)

* feat(vacay): support half vacation days (#552)

Log a vacation day as a half day (0.5) as well as a full day and count it
as such against the entitlement. A "Half day" mode joins the calendar
toolbar on desktop and mobile; half days render as a diagonal half-fill in
the day cell, and the used/remaining totals carry the fraction.

Closes #552

* refactor(vacay): half-day as a per-person toggle with a corner badge

The diagonal half-fill collided with the two-person split — one person's
half day looked like two people off. Drop the diagonal: the cell fill keeps
showing who is off, a half day gets a small corner ½ badge, and a hover
tooltip lists who is off on that date and how much (only when a half day is
present). Logging moves from a third mode to a Half-day toggle that applies
to the selected person, on desktop and mobile.

* feat(vacay): mark half days with an orange corner dot

Swap the ½ badge for a small orange dot in the bottom-right corner, mirroring
the blue trip dot — quieter in the grid, and the hover tooltip still spells
out who is on a half day.

* fix(vacay): make the fraction migration idempotent

The crosswalk test replays the migration tail (rewind + re-run), which expects
every later migration to be idempotent. Guard the vacay_entries ADD COLUMN
behind a pragma_table_info check so the second run is a no-op. Also only set
dayVisual's `half` flag when true, so full days keep their exact
{ background, numColor } shape for the mobile day-model unit test.

* style(vacay): nudge the mobile day dots slightly inward

The blue trip dot and orange half-day dot sat right in the cell corner on
mobile; move both a touch toward the centre so they read cleaner.
This commit is contained in:
Maurice
2026-07-20 23:38:47 +02:00
committed by GitHub
parent a3cb783df7
commit 7528706d00
43 changed files with 372 additions and 56 deletions
+8
View File
@@ -3701,6 +3701,14 @@ function runMigrations(db: Database.Database): void {
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id);');
},
// Half vacation days (#552): a vacay entry can now count as a full day (1) or
// a half day (0.5) toward the entitlement. Existing entries default to a full
// day, so the entitlement maths are unchanged for everyone already using vacay.
// Guarded so re-running the migration tail (e.g. the crosswalk test) is a no-op.
() => {
const hasFraction = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'fraction'").get();
if (!hasFraction) db.exec('ALTER TABLE vacay_entries ADD COLUMN fraction REAL NOT NULL DEFAULT 1');
},
];
if (currentVersion < migrations.length) {
+1 -1
View File
@@ -253,7 +253,7 @@ export function registerVacayTools(server: McpServer, userId: number, scopes: st
async ({ date }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleEntry(userId, planId, date, undefined);
const result = toggleEntry(userId, planId, date, 1, undefined);
return ok(result);
}
);
@@ -695,7 +695,7 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, rout
},
// --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side);
// the service broadcasts to plan users itself. ---
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, undefined); },
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, 1, undefined); },
vacayToggleCompanyHoliday: (userId, date, note) => {
requireAddon(ADDON_IDS.VACAY, 'vacay');
return vacayToggleCompanyHolidaySvc(getActivePlanId(userId), date, note, undefined);
+2 -2
View File
@@ -182,7 +182,7 @@ export class VacayController {
@HttpCode(200)
toggleEntry(
@CurrentUser() user: User,
@Body() body: { date?: string; target_user_id?: number | string },
@Body() body: { date?: string; target_user_id?: number | string; fraction?: number },
@Headers('x-socket-id') socketId?: string,
) {
if (!body.date) {
@@ -197,7 +197,7 @@ export class VacayController {
}
userId = tid;
}
return this.vacay.toggleEntry(userId, planId, body.date, socketId);
return this.vacay.toggleEntry(userId, planId, body.date, body.fraction, socketId);
}
@Post('entries/company-holiday')
+2 -2
View File
@@ -86,8 +86,8 @@ export class VacayService {
return svc.getEntries(planId, year);
}
toggleEntry(userId: number, planId: number, date: string, socketId: string | undefined) {
return svc.toggleEntry(userId, planId, date, socketId);
toggleEntry(userId: number, planId: number, date: string, fraction: unknown, socketId: string | undefined) {
return svc.toggleEntry(userId, planId, date, fraction, socketId);
}
toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined) {
+40 -12
View File
@@ -60,6 +60,27 @@ export interface VacayHolidayCalendar {
const holidayCache = new Map<string, { data: unknown; time: number }>();
const CACHE_TTL = 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// Entitlement helpers
// ---------------------------------------------------------------------------
/**
* Vacation days a user has used in a year — the SUM of entry fractions, so a
* half day (#552) counts as 0.5 and a full day as 1. Entries predating the
* feature have fraction = 1, so this matches the old COUNT(*) for them.
*/
function usedDays(userId: number, planId: number, yearPrefix: string): number {
const row = db.prepare(
'SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?'
).get(userId, planId, yearPrefix) as { used: number };
return row.used;
}
/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */
function normalizeFraction(value: unknown): number {
return Number(value) === 0.5 ? 0.5 : 1;
}
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
@@ -251,7 +272,7 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId:
const yr = years[i].year;
const nextYr = years[i + 1].year;
for (const u of users) {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`) as { count: number }).count;
const used = usedDays(u.id, planId, `${yr}-%`);
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr) as VacayUserYear | undefined;
const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
const carry = Math.max(0, total - used);
@@ -505,7 +526,7 @@ export function addYear(planId: number, year: number, socketId: string | undefin
if (carryOverEnabled) {
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1) as VacayUserYear | undefined;
if (prevConfig) {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`) as { count: number }).count;
const used = usedDays(u.id, planId, `${year - 1}-%`);
const total = prevConfig.vacation_days + prevConfig.carried_over;
carriedOver = Math.max(0, total - used);
}
@@ -536,7 +557,7 @@ export function deleteYear(planId: number, year: number, socketId: string | unde
if (carryOverEnabled && prevYear) {
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, prevYear.year) as VacayUserYear | undefined;
if (prevConfig) {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${prevYear.year}-%`) as { count: number }).count;
const used = usedDays(u.id, planId, `${prevYear.year}-%`);
const total = prevConfig.vacation_days + prevConfig.carried_over;
carry = Math.max(0, total - used);
}
@@ -565,17 +586,24 @@ export function getEntries(planId: number, year: string) {
return { entries, companyHolidays };
}
export function toggleEntry(userId: number, planId: number, date: string, socketId: string | undefined): { action: string } {
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number } | undefined;
export function toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, socketId?: string): { action: string; fraction?: number } {
const frac = normalizeFraction(fraction);
const existing = db.prepare('SELECT id, fraction FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number; fraction: number } | undefined;
if (existing) {
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
// Clicking the same kind of day again clears it; clicking the other kind
// (full over a half, or vice versa) converts it in place.
if (existing.fraction === frac) {
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
notifyPlanUsers(planId, socketId);
return { action: 'removed' };
}
db.prepare('UPDATE vacay_entries SET fraction = ? WHERE id = ?').run(frac, existing.id);
notifyPlanUsers(planId, socketId);
return { action: 'removed' };
} else {
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
notifyPlanUsers(planId, socketId);
return { action: 'added' };
return { action: 'updated', fraction: frac };
}
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction) VALUES (?, ?, ?, ?, ?)').run(planId, userId, date, '', frac);
notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac };
}
export function toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } {
@@ -602,7 +630,7 @@ export function getStats(planId: number, year: number) {
const users = getPlanUsers(planId);
return users.map(u => {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`) as { count: number }).count;
const used = usedDays(u.id, planId, `${year}-%`);
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year) as VacayUserYear | undefined;
const vacationDays = config ? config.vacation_days : 30;
const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;