mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
* 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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user