Adds work hours management with day-specific overrides
Introduces work hours management with weekly default and date-specific overrides to support per-column scheduling. Calculates and applies non-work hour overlays to columns. This allows users to visualize working and non-working times. Adjusts event rendering to prevent overlap with horizontal timelines.
This commit is contained in:
parent
18c12cd3e6
commit
a3ed03ff44
5 changed files with 252 additions and 13 deletions
160
src/managers/WorkHoursManager.ts
Normal file
160
src/managers/WorkHoursManager.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// Work hours management for per-column scheduling
|
||||||
|
|
||||||
|
import { DateCalculator } from '../utils/DateCalculator';
|
||||||
|
import { CalendarConfig } from '../core/CalendarConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work hours for a specific day
|
||||||
|
*/
|
||||||
|
export interface DayWorkHours {
|
||||||
|
start: number; // Hour (0-23)
|
||||||
|
end: number; // Hour (0-23)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work schedule configuration
|
||||||
|
*/
|
||||||
|
export interface WorkScheduleConfig {
|
||||||
|
weeklyDefault: {
|
||||||
|
monday: DayWorkHours | 'off';
|
||||||
|
tuesday: DayWorkHours | 'off';
|
||||||
|
wednesday: DayWorkHours | 'off';
|
||||||
|
thursday: DayWorkHours | 'off';
|
||||||
|
friday: DayWorkHours | 'off';
|
||||||
|
saturday: DayWorkHours | 'off';
|
||||||
|
sunday: DayWorkHours | 'off';
|
||||||
|
};
|
||||||
|
dateOverrides: {
|
||||||
|
[dateString: string]: DayWorkHours | 'off'; // YYYY-MM-DD format
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages work hours scheduling with weekly defaults and date-specific overrides
|
||||||
|
*/
|
||||||
|
export class WorkHoursManager {
|
||||||
|
private config: CalendarConfig;
|
||||||
|
private dateCalculator: DateCalculator;
|
||||||
|
private workSchedule: WorkScheduleConfig;
|
||||||
|
|
||||||
|
constructor(config: CalendarConfig) {
|
||||||
|
this.config = config;
|
||||||
|
this.dateCalculator = new DateCalculator(config);
|
||||||
|
|
||||||
|
// Default work schedule - will be loaded from JSON later
|
||||||
|
this.workSchedule = {
|
||||||
|
weeklyDefault: {
|
||||||
|
monday: { start: 9, end: 17 },
|
||||||
|
tuesday: { start: 9, end: 17 },
|
||||||
|
wednesday: { start: 9, end: 17 },
|
||||||
|
thursday: { start: 9, end: 17 },
|
||||||
|
friday: { start: 9, end: 15 },
|
||||||
|
saturday: 'off',
|
||||||
|
sunday: 'off'
|
||||||
|
},
|
||||||
|
dateOverrides: {
|
||||||
|
'2025-01-20': { start: 10, end: 16 },
|
||||||
|
'2025-01-21': { start: 8, end: 14 },
|
||||||
|
'2025-01-22': 'off'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get work hours for a specific date
|
||||||
|
*/
|
||||||
|
getWorkHoursForDate(date: Date): DayWorkHours | 'off' {
|
||||||
|
const dateString = this.dateCalculator.formatISODate(date);
|
||||||
|
|
||||||
|
// Check for date-specific override first
|
||||||
|
if (this.workSchedule.dateOverrides[dateString]) {
|
||||||
|
return this.workSchedule.dateOverrides[dateString];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to weekly default
|
||||||
|
const dayName = this.getDayName(date);
|
||||||
|
return this.workSchedule.weeklyDefault[dayName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get work hours for multiple dates (used by GridManager)
|
||||||
|
*/
|
||||||
|
getWorkHoursForDateRange(dates: Date[]): Map<string, DayWorkHours | 'off'> {
|
||||||
|
const workHoursMap = new Map<string, DayWorkHours | 'off'>();
|
||||||
|
|
||||||
|
dates.forEach(date => {
|
||||||
|
const dateString = this.dateCalculator.formatISODate(date);
|
||||||
|
const workHours = this.getWorkHoursForDate(date);
|
||||||
|
workHoursMap.set(dateString, workHours);
|
||||||
|
});
|
||||||
|
|
||||||
|
return workHoursMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate CSS custom properties for non-work hour overlays (before and after work)
|
||||||
|
*/
|
||||||
|
calculateNonWorkHoursStyle(workHours: DayWorkHours | 'off'): { beforeWorkHeight: number; afterWorkTop: number } | null {
|
||||||
|
if (workHours === 'off') {
|
||||||
|
return null; // Full day will be colored via CSS background
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridSettings = this.config.getGridSettings();
|
||||||
|
const dayStartHour = gridSettings.dayStartHour;
|
||||||
|
const dayEndHour = gridSettings.dayEndHour;
|
||||||
|
const hourHeight = gridSettings.hourHeight;
|
||||||
|
|
||||||
|
// Before work: from day start to work start
|
||||||
|
const beforeWorkHeight = (workHours.start - dayStartHour) * hourHeight;
|
||||||
|
|
||||||
|
// After work: from work end to day end
|
||||||
|
const afterWorkTop = (workHours.end - dayStartHour) * hourHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
beforeWorkHeight: Math.max(0, beforeWorkHeight),
|
||||||
|
afterWorkTop: Math.max(0, afterWorkTop)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate CSS custom properties for work hours overlay (legacy - for backward compatibility)
|
||||||
|
*/
|
||||||
|
calculateWorkHoursStyle(workHours: DayWorkHours | 'off'): { top: number; height: number } | null {
|
||||||
|
if (workHours === 'off') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridSettings = this.config.getGridSettings();
|
||||||
|
const dayStartHour = gridSettings.dayStartHour;
|
||||||
|
const hourHeight = gridSettings.hourHeight;
|
||||||
|
|
||||||
|
const top = (workHours.start - dayStartHour) * hourHeight;
|
||||||
|
const height = (workHours.end - workHours.start) * hourHeight;
|
||||||
|
|
||||||
|
return { top, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load work schedule from JSON (future implementation)
|
||||||
|
*/
|
||||||
|
async loadWorkSchedule(jsonData: WorkScheduleConfig): Promise<void> {
|
||||||
|
this.workSchedule = jsonData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current work schedule configuration
|
||||||
|
*/
|
||||||
|
getWorkSchedule(): WorkScheduleConfig {
|
||||||
|
return this.workSchedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Date to day name key
|
||||||
|
*/
|
||||||
|
private getDayName(date: Date): keyof WorkScheduleConfig['weeklyDefault'] {
|
||||||
|
const dayNames: (keyof WorkScheduleConfig['weeklyDefault'])[] = [
|
||||||
|
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'
|
||||||
|
];
|
||||||
|
return dayNames[date.getDay()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { CalendarConfig } from '../core/CalendarConfig';
|
import { CalendarConfig } from '../core/CalendarConfig';
|
||||||
import { ResourceCalendarData } from '../types/CalendarTypes';
|
import { ResourceCalendarData } from '../types/CalendarTypes';
|
||||||
import { DateCalculator } from '../utils/DateCalculator';
|
import { DateCalculator } from '../utils/DateCalculator';
|
||||||
|
import { WorkHoursManager } from '../managers/WorkHoursManager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for column rendering strategies
|
* Interface for column rendering strategies
|
||||||
|
|
@ -25,23 +26,28 @@ export interface ColumnRenderContext {
|
||||||
*/
|
*/
|
||||||
export class DateColumnRenderer implements ColumnRenderer {
|
export class DateColumnRenderer implements ColumnRenderer {
|
||||||
private dateCalculator!: DateCalculator;
|
private dateCalculator!: DateCalculator;
|
||||||
|
private workHoursManager!: WorkHoursManager;
|
||||||
|
|
||||||
render(columnContainer: HTMLElement, context: ColumnRenderContext): void {
|
render(columnContainer: HTMLElement, context: ColumnRenderContext): void {
|
||||||
const { currentWeek, config } = context;
|
const { currentWeek, config } = context;
|
||||||
|
|
||||||
// Initialize date calculator
|
// Initialize date calculator and work hours manager
|
||||||
this.dateCalculator = new DateCalculator(config);
|
this.dateCalculator = new DateCalculator(config);
|
||||||
|
this.workHoursManager = new WorkHoursManager(config);
|
||||||
|
|
||||||
const dates = this.dateCalculator.getWorkWeekDates(currentWeek);
|
const dates = this.dateCalculator.getWorkWeekDates(currentWeek);
|
||||||
const dateSettings = config.getDateViewSettings();
|
const dateSettings = config.getDateViewSettings();
|
||||||
const daysToShow = dates.slice(0, dateSettings.weekDays);
|
const daysToShow = dates.slice(0, dateSettings.weekDays);
|
||||||
|
|
||||||
console.log('DateColumnRenderer: About to render', daysToShow.length, 'date columns');
|
console.log('DateColumnRenderer: About to render', daysToShow.length, 'date columns with work hours');
|
||||||
|
|
||||||
daysToShow.forEach((date) => {
|
daysToShow.forEach((date) => {
|
||||||
const column = document.createElement('swp-day-column');
|
const column = document.createElement('swp-day-column');
|
||||||
(column as any).dataset.date = this.dateCalculator.formatISODate(date);
|
(column as any).dataset.date = this.dateCalculator.formatISODate(date);
|
||||||
|
|
||||||
|
// Apply work hours styling
|
||||||
|
this.applyWorkHoursToColumn(column, date);
|
||||||
|
|
||||||
const eventsLayer = document.createElement('swp-events-layer');
|
const eventsLayer = document.createElement('swp-events-layer');
|
||||||
column.appendChild(eventsLayer);
|
column.appendChild(eventsLayer);
|
||||||
|
|
||||||
|
|
@ -49,6 +55,28 @@ export class DateColumnRenderer implements ColumnRenderer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyWorkHoursToColumn(column: HTMLElement, date: Date): void {
|
||||||
|
const workHours = this.workHoursManager.getWorkHoursForDate(date);
|
||||||
|
|
||||||
|
if (workHours === 'off') {
|
||||||
|
// No work hours - mark as off day (full day will be colored)
|
||||||
|
(column as any).dataset.workHours = 'off';
|
||||||
|
console.log(`DateColumnRenderer: ${this.dateCalculator.formatISODate(date)} is an off day`);
|
||||||
|
} else {
|
||||||
|
// Calculate and apply non-work hours overlays (before and after work)
|
||||||
|
const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours);
|
||||||
|
if (nonWorkStyle) {
|
||||||
|
// Before work overlay (::before pseudo-element)
|
||||||
|
column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`);
|
||||||
|
|
||||||
|
// After work overlay (::after pseudo-element)
|
||||||
|
column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`);
|
||||||
|
|
||||||
|
console.log(`DateColumnRenderer: ${this.dateCalculator.formatISODate(date)} non-work overlays - before: ${nonWorkStyle.beforeWorkHeight}px, after: ${nonWorkStyle.afterWorkTop}px (work hours: ${workHours.start}-${workHours.end})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
const position = this.calculateEventPosition(event, config);
|
const position = this.calculateEventPosition(event, config);
|
||||||
eventElement.style.position = 'absolute';
|
eventElement.style.position = 'absolute';
|
||||||
eventElement.style.top = `${position.top + 1}px`;
|
eventElement.style.top = `${position.top + 1}px`;
|
||||||
eventElement.style.height = `${position.height - 1}px`;
|
eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines.
|
||||||
|
|
||||||
// Color is now handled by CSS classes based on data-type attribute
|
// Color is now handled by CSS classes based on data-type attribute
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
--color-grid-line: #e0e0e0;
|
--color-grid-line: #e0e0e0;
|
||||||
--color-grid-line-light: rgba(0, 0, 0, 0.05);
|
--color-grid-line-light: rgba(0, 0, 0, 0.05);
|
||||||
--color-hour-line: rgba(0, 0, 0, 0.2);
|
--color-hour-line: rgba(0, 0, 0, 0.2);
|
||||||
--color-work-hours: rgba(242, 242, 242, 1);
|
--color-work-hours: rgba(255, 255, 255, 0.9);
|
||||||
--color-current-time: #ff0000;
|
--color-current-time: #ff0000;
|
||||||
|
|
||||||
/* Event colors - Updated with month-view-expanded.html color scheme */
|
/* Event colors - Updated with month-view-expanded.html color scheme */
|
||||||
|
|
@ -53,6 +53,8 @@
|
||||||
/* UI colors */
|
/* UI colors */
|
||||||
--color-background: #ffffff;
|
--color-background: #ffffff;
|
||||||
--color-surface: #f5f5f5;
|
--color-surface: #f5f5f5;
|
||||||
|
--color-event-grid: #ffffff;
|
||||||
|
--color-non-work-hours: #ff980038;
|
||||||
--color-text: #333333;
|
--color-text: #333333;
|
||||||
--color-text-secondary: #666666;
|
--color-text-secondary: #666666;
|
||||||
--color-border: #e0e0e0;
|
--color-border: #e0e0e0;
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ swp-hour-marker {
|
||||||
swp-hour-marker::before {
|
swp-hour-marker::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1px;
|
top: 0px;
|
||||||
left: 50px;
|
left: 50px;
|
||||||
width: calc(100vw - 60px); /* Full viewport width minus time-axis width */
|
width: calc(100vw - 60px); /* Full viewport width minus time-axis width */
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|
@ -180,7 +180,6 @@ swp-calendar-header::-webkit-scrollbar-track {
|
||||||
|
|
||||||
|
|
||||||
swp-day-header {
|
swp-day-header {
|
||||||
padding: 12px;
|
|
||||||
pointer-events: auto; /* Ensure header clicks work despite parent scrollbar */
|
pointer-events: auto; /* Ensure header clicks work despite parent scrollbar */
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-right: 1px solid var(--color-grid-line);
|
border-right: 1px solid var(--color-grid-line);
|
||||||
|
|
@ -189,6 +188,7 @@ swp-day-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding-top: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-day-header:last-child {
|
swp-day-header:last-child {
|
||||||
|
|
@ -239,23 +239,25 @@ swp-resource-name {
|
||||||
swp-day-name {
|
swp-day-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.875rem;
|
font-size: 12px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-day-date {
|
swp-day-date {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1.25rem;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
height: 41px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-day-header[data-today="true"] swp-day-date {
|
swp-day-header[data-today="true"] swp-day-date {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: rgba(33, 150, 243, 0.1);
|
background: rgba(33, 150, 243, 0.1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 36px;
|
width: 41px;
|
||||||
height: 36px;
|
height: 41px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -333,16 +335,18 @@ swp-time-grid {
|
||||||
height: calc((var(--day-end-hour) - var(--day-start-hour)) * var(--hour-height));
|
height: calc((var(--day-end-hour) - var(--day-start-hour)) * var(--hour-height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global work hours overlay - now disabled, replaced by per-column overlays */
|
||||||
swp-time-grid::before {
|
swp-time-grid::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc((var(--work-start-hour) - var(--day-start-hour)) * var(--hour-height));
|
top: 0;
|
||||||
height: calc((var(--work-end-hour) - var(--work-start-hour)) * var(--hour-height));
|
height: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--color-work-hours);
|
background: transparent;
|
||||||
min-width: calc(var(--grid-columns, 7) * var(--day-column-min-width));
|
min-width: calc(var(--grid-columns, 7) * var(--day-column-min-width));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
display: none; /* Disabled - using per-column overlays instead */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid lines */
|
/* Grid lines */
|
||||||
|
|
@ -379,6 +383,50 @@ swp-day-column {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-right: 1px solid var(--color-grid-line);
|
border-right: 1px solid var(--color-grid-line);
|
||||||
min-width: var(--day-column-min-width);
|
min-width: var(--day-column-min-width);
|
||||||
|
background: var(--color-event-grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-column non-work hours overlays */
|
||||||
|
/* Before work overlay */
|
||||||
|
swp-day-column::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-non-work-hours);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
/* Before work period - from day start to work start */
|
||||||
|
top: 0;
|
||||||
|
height: var(--before-work-height, 0px);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* After work overlay */
|
||||||
|
swp-day-column::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-non-work-hours);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
/* After work period - from work end to day end */
|
||||||
|
top: var(--after-work-top, 100%);
|
||||||
|
bottom: 0;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full day overlay when day is off */
|
||||||
|
swp-day-column[data-work-hours="off"] {
|
||||||
|
background: var(--color-non-work-hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-day-column[data-work-hours="off"]::before,
|
||||||
|
swp-day-column[data-work-hours="off"]::after {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-day-column:last-child {
|
swp-day-column:last-child {
|
||||||
|
|
@ -390,6 +438,7 @@ swp-resource-column {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-right: 1px solid var(--color-grid-line);
|
border-right: 1px solid var(--color-grid-line);
|
||||||
min-width: var(--day-column-min-width);
|
min-width: var(--day-column-min-width);
|
||||||
|
background: var(--color-event-grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-resource-column:last-child {
|
swp-resource-column:last-child {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue