@luckydye/calendar 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ export function createGridLayer(): CalendarLayer {
11
11
  width,
12
12
  height,
13
13
  scrollTop,
14
+ scrollLeft,
14
15
  dayWidth,
15
16
  dayHeight,
16
17
  leftGutterWidth,
@@ -34,22 +35,18 @@ export function createGridLayer(): CalendarLayer {
34
35
  );
35
36
  if (todayIndex >= 0) {
36
37
  const { row, col } = getDayVisualPosition(todayIndex);
37
- const x = leftGutterWidth + col * dayWidth;
38
+ const x = leftGutterWidth + col * dayWidth - scrollLeft;
38
39
  const dayY = week.yOffset + row * dayHeight - scrollTop;
39
- ctx.fillStyle =
40
- styles["--bg-today"] || "rgba(255, 255, 255, 0.05)";
40
+ ctx.fillStyle = styles["--bg-today"] || "rgba(255, 255, 255, 0.05)";
41
41
  ctx.fillRect(x, dayY, dayWidth, dayHeight);
42
42
 
43
43
  if (showTimeScale) {
44
44
  const now = new Date();
45
- const currentMinutes =
46
- now.getHours() * 60 + now.getMinutes();
47
- const timeY =
48
- dayY + (currentMinutes / 1440) * dayHeight;
45
+ const currentMinutes = now.getHours() * 60 + now.getMinutes();
46
+ const timeY = dayY + (currentMinutes / 1440) * dayHeight;
49
47
  if (timeY >= 0 && timeY <= height) {
50
48
  ctx.strokeStyle =
51
- styles["--accent-current-time"] ||
52
- "rgba(255, 0, 0, 0.8)";
49
+ styles["--accent-current-time"] || "rgba(255, 0, 0, 0.8)";
53
50
  ctx.lineWidth = 1;
54
51
  ctx.beginPath();
55
52
  ctx.moveTo(x, timeY);
@@ -62,19 +59,29 @@ export function createGridLayer(): CalendarLayer {
62
59
  }
63
60
 
64
61
  // Draw grid lines
65
- const gridColor =
66
- styles["--grid-color"] || "rgba(255, 255, 255, 0.1)";
62
+ const gridColor = styles["--grid-color"] || "rgba(255, 255, 255, 0.1)";
63
+ const textMuted = styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
67
64
  ctx.strokeStyle = gridColor;
68
65
  ctx.lineWidth = 1;
69
66
 
70
67
  // Vertical lines (day separators)
68
+ const hourLabelOpacity = Math.max(
69
+ 0,
70
+ Math.min(1, (dayHeight - 300) / 300),
71
+ );
72
+ ctx.strokeStyle =
73
+ hourLabelOpacity > 0.1
74
+ ? textMuted.replace(/[\d.]+\)$/, `${0.2 * hourLabelOpacity})`)
75
+ : gridColor;
71
76
  for (let i = 1; i <= columnsPerRow; i++) {
72
- const x = leftGutterWidth + i * dayWidth;
77
+ const x = leftGutterWidth + i * dayWidth - scrollLeft;
73
78
  ctx.beginPath();
74
79
  ctx.moveTo(x, 0);
75
80
  ctx.lineTo(x, height);
76
81
  ctx.stroke();
77
82
  }
83
+ ctx.strokeStyle = gridColor;
84
+ ctx.lineWidth = 1;
78
85
 
79
86
  // Horizontal lines (week separators) and left gutter content
80
87
  for (let i = 0; i < visibleWeeks.length; i++) {
@@ -93,8 +100,7 @@ export function createGridLayer(): CalendarLayer {
93
100
  if (hiddenWeeks > 0) {
94
101
  const gapY = y;
95
102
  const gridColorStrong =
96
- styles["--grid-color-strong"] ||
97
- "rgba(255, 255, 255, 0.3)";
103
+ styles["--grid-color-strong"] || "rgba(255, 255, 255, 0.3)";
98
104
  ctx.strokeStyle = gridColorStrong;
99
105
  ctx.lineWidth = 1;
100
106
  ctx.setLineDash([4, 4]);
@@ -105,43 +111,29 @@ export function createGridLayer(): CalendarLayer {
105
111
  ctx.setLineDash([]);
106
112
 
107
113
  const textMuted =
108
- styles["--text-muted"] ||
109
- "rgba(255, 255, 255, 0.4)";
114
+ styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
110
115
  ctx.fillStyle = textMuted;
111
116
  ctx.textAlign = "center";
112
- const ellipsisText = `⋯ ${hiddenWeeks} week${hiddenWeeks > 1 ? "s" : ""}`;
117
+ const ellipsisText = `⋯ ${hiddenWeeks} week${
118
+ hiddenWeeks > 1 ? "s" : ""
119
+ }`;
113
120
 
114
- const textWidth =
115
- ctx.measureText(ellipsisText).width;
121
+ const textWidth = ctx.measureText(ellipsisText).width;
116
122
  const pillPadding = 8;
117
123
  const pillX =
118
- (leftGutterWidth + width) / 2 -
119
- textWidth / 2 -
120
- pillPadding;
124
+ (leftGutterWidth + width) / 2 - textWidth / 2 - pillPadding;
121
125
  const pillY = gapY - 8;
122
126
  const pillWidth = textWidth + pillPadding * 2;
123
127
  const pillHeight = 16;
124
128
 
125
- const bgPrimary =
126
- styles["--bg-primary"] ||
127
- "rgba(30, 30, 30, 0.9)";
129
+ const bgPrimary = styles["--bg-primary"] || "rgba(30, 30, 30, 0.9)";
128
130
  ctx.fillStyle = bgPrimary;
129
131
  ctx.beginPath();
130
- ctx.roundRect(
131
- pillX,
132
- pillY,
133
- pillWidth,
134
- pillHeight,
135
- 8,
136
- );
132
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 8);
137
133
  ctx.fill();
138
134
 
139
135
  ctx.fillStyle = textMuted;
140
- ctx.fillText(
141
- ellipsisText,
142
- (leftGutterWidth + width) / 2,
143
- gapY + 4,
144
- );
136
+ ctx.fillText(ellipsisText, (leftGutterWidth + width) / 2, gapY + 4);
145
137
  }
146
138
  }
147
139
 
@@ -164,25 +156,10 @@ export function createGridLayer(): CalendarLayer {
164
156
  }
165
157
 
166
158
  // Left gutter: hour lines and labels
167
- const hourLabelOpacity = Math.max(
168
- 0,
169
- Math.min(1, (dayHeight - 300) / 300),
170
- );
171
-
172
- ctx.font = `500 11px ${fontFamily}`;
173
- ctx.textBaseline = "bottom";
174
- ctx.textAlign = "right";
175
-
176
- const textMuted =
177
- styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
178
159
  ctx.strokeStyle = gridColor.replace(
179
160
  /[\d.]+\)$/,
180
161
  `${0.05 * hourLabelOpacity})`,
181
162
  );
182
- ctx.fillStyle = textMuted.replace(
183
- /[\d.]+\)$/,
184
- `${0.4 * hourLabelOpacity})`,
185
- );
186
163
 
187
164
  for (let row = 0; row < rowsPerWeek; row++) {
188
165
  const rowY = y + row * dayHeight;
@@ -193,70 +170,6 @@ export function createGridLayer(): CalendarLayer {
193
170
  ctx.moveTo(leftGutterWidth, hourY);
194
171
  ctx.lineTo(width, hourY);
195
172
  ctx.stroke();
196
-
197
- if (hourLabelOpacity > 0.1) {
198
- const label = `${hour.toString().padStart(2, "0")}:00`;
199
- ctx.fillText(label, 48, hourY + 4);
200
- }
201
- }
202
- }
203
- }
204
-
205
- // Current time indicator in left gutter
206
- if (hourLabelOpacity > 0.1) {
207
- const todayIndex = week.days.findIndex((d) =>
208
- CalendarInternal.isSameDay(d, today),
209
- );
210
-
211
- if (todayIndex >= 0) {
212
- const { row } = getDayVisualPosition(todayIndex);
213
- const currentMinutes =
214
- today.getHours() * 60 + today.getMinutes();
215
- const timeY =
216
- y +
217
- row * dayHeight +
218
- (currentMinutes / 1440) * dayHeight;
219
-
220
- if (timeY >= 0 && timeY <= height) {
221
- const hours = today
222
- .getHours()
223
- .toString()
224
- .padStart(2, "0");
225
- const minutes = today
226
- .getMinutes()
227
- .toString()
228
- .padStart(2, "0");
229
- const timeText = `${hours}:${minutes}`;
230
-
231
- ctx.save();
232
- ctx.textAlign = "right";
233
- ctx.textBaseline = "middle";
234
-
235
- const textWidth =
236
- ctx.measureText(timeText).width;
237
- const bgPaddingX = 6;
238
- const textX = 48;
239
- const textY = timeY;
240
-
241
- const bgElevated =
242
- styles["--bg-elevated"] ||
243
- "rgba(0, 0, 0, 0.7)";
244
- ctx.fillStyle = bgElevated;
245
- ctx.beginPath();
246
- ctx.roundRect(
247
- textX - textWidth - bgPaddingX,
248
- textY - 8,
249
- textWidth + bgPaddingX * 2,
250
- 16,
251
- 4,
252
- );
253
- ctx.fill();
254
-
255
- ctx.fillStyle =
256
- styles["--text-primary"] ||
257
- "rgba(255, 255, 255, 1)";
258
- ctx.fillText(timeText, textX, textY);
259
- ctx.restore();
260
173
  }
261
174
  }
262
175
  }
@@ -268,28 +181,35 @@ export function createGridLayer(): CalendarLayer {
268
181
  );
269
182
  const textMutedForWeek =
270
183
  styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
271
- ctx.fillStyle = textMutedForWeek.replace(
272
- /[\d.]+\)$/,
273
- `${0.4 * weekNumberOpacity})`,
184
+ const isCurrentWeek = week.days.some((day) =>
185
+ CalendarInternal.isSameDay(day, today),
274
186
  );
187
+ ctx.save();
188
+ ctx.font = `500 11px ${fontFamily}`;
189
+ ctx.textBaseline = "middle";
190
+ ctx.fillStyle = isCurrentWeek
191
+ ? "rgba(255, 255, 255, 1)"
192
+ : textMutedForWeek.replace(
193
+ /[\d.]+\)$/,
194
+ `${0.4 * weekNumberOpacity})`,
195
+ );
275
196
  ctx.textAlign = "center";
276
197
  const label = `W${week.weekNumber}`;
277
198
  const weekTop = y;
278
199
  const weekBottom = y + week.height;
200
+ const topClearance = 52;
279
201
  const labelY = Math.max(
280
- 14,
281
- Math.min(
282
- weekTop + week.height / 2 + 4,
283
- weekBottom - 4,
284
- ),
202
+ topClearance,
203
+ Math.min(weekTop + week.height / 2 + 4, weekBottom - 4),
285
204
  );
286
205
  if (
287
- labelY >= Math.max(0, weekTop + 4) &&
206
+ labelY >= Math.max(topClearance, weekTop + 4) &&
288
207
  labelY <= Math.min(height, weekBottom) &&
289
208
  weekNumberOpacity > 0.1
290
209
  ) {
291
210
  ctx.fillText(label, 30, labelY);
292
211
  }
212
+ ctx.restore();
293
213
  }
294
214
  },
295
215
  };
@@ -2,131 +2,134 @@ import type { CalendarEvent, WeekInfo } from "../CalendarInternal.js";
2
2
  import type { CalendarLayer, LayerContext } from "../CalendarLayer.js";
3
3
 
4
4
  export interface HeatmapState {
5
- events: CalendarEvent[];
5
+ events: CalendarEvent[];
6
6
  }
7
7
 
8
8
  function getBucketMinutes(dayHeight: number): number {
9
- if (dayHeight >= 320) return 15;
10
- if (dayHeight >= 240) return 30;
11
- if (dayHeight >= 160) return 60;
12
- return 120;
9
+ if (dayHeight >= 320) return 15;
10
+ if (dayHeight >= 240) return 30;
11
+ if (dayHeight >= 160) return 60;
12
+ return 120;
13
13
  }
14
14
 
15
15
  export function createTimeseriesHeatmapLayer(
16
- state: HeatmapState,
16
+ state: HeatmapState,
17
17
  ): CalendarLayer {
18
- return {
19
- name: "timeseries-heatmap",
20
- enabled: true,
21
- render(lc: LayerContext): void {
22
- const {
23
- ctx,
24
- height,
25
- scrollTop,
26
- dayWidth,
27
- dayHeight,
28
- leftGutterWidth,
29
- visibleWeeks,
30
- allWeeks,
31
- getDayVisualPosition,
32
- } = lc;
33
-
34
- const events = state.events.filter(
35
- (event) => event.visualStyle === "heatmap",
36
- );
37
- if (events.length === 0 || visibleWeeks.length === 0) return;
38
-
39
- const dayInfoMap = new Map<
40
- number,
41
- {
42
- week: WeekInfo;
43
- weekIndex: number;
44
- dayIndex: number;
45
- row: number;
46
- col: number;
47
- dayStartMs: number;
48
- }
49
- >();
50
-
51
- for (const week of visibleWeeks) {
52
- const weekIndex = allWeeks.indexOf(week);
53
- if (weekIndex < 0) continue;
54
- for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
55
- const day = week.days[dayIndex];
56
- if (!day) continue;
57
- const dayStart = new Date(day);
58
- dayStart.setHours(0, 0, 0, 0);
59
- const { row, col } = getDayVisualPosition(dayIndex);
60
- dayInfoMap.set(dayStart.getTime(), {
61
- week,
62
- weekIndex,
63
- dayIndex,
64
- row,
65
- col,
66
- dayStartMs: dayStart.getTime(),
67
- });
68
- }
69
- }
70
-
71
- if (dayInfoMap.size === 0) return;
72
-
73
- const bucketMinutes = getBucketMinutes(dayHeight);
74
- const bucketCounts = new Map<
75
- string,
76
- { count: number; color: string; dayKey: number; bucket: number }
77
- >();
78
- let maxCount = 0;
79
-
80
- for (const event of events) {
81
- const start = event.start;
82
- const dayStart = new Date(start);
83
- dayStart.setHours(0, 0, 0, 0);
84
- const dayKey = dayStart.getTime();
85
- const dayInfo = dayInfoMap.get(dayKey);
86
- if (!dayInfo) continue;
87
-
88
- const minutesIntoDay = (start.getTime() - dayInfo.dayStartMs) / 60000;
89
- if (minutesIntoDay < 0 || minutesIntoDay >= 1440) continue;
90
-
91
- const bucket = Math.floor(minutesIntoDay / bucketMinutes);
92
- const color = event.color || "rgba(255, 255, 255, 0.6)";
93
- const key = `${dayKey}-${bucket}-${color}`;
94
- const entry =
95
- bucketCounts.get(key) ||
96
- { count: 0, color, dayKey, bucket };
97
- entry.count += 1;
98
- bucketCounts.set(key, entry);
99
- if (entry.count > maxCount) maxCount = entry.count;
100
- }
101
-
102
- if (maxCount === 0) return;
103
-
104
- const bucketHeight =
105
- (bucketMinutes / 1440) * dayHeight;
106
- const minHeight = Math.max(2, bucketHeight);
107
-
108
- for (const entry of bucketCounts.values()) {
109
- const dayInfo = dayInfoMap.get(entry.dayKey);
110
- if (!dayInfo) continue;
111
-
112
- const intensity = Math.log(entry.count + 1) / Math.log(maxCount + 1);
113
- const alpha = 0.08 + intensity * 0.22;
114
-
115
- const x = leftGutterWidth + dayInfo.col * dayWidth + 1;
116
- const y =
117
- dayInfo.week.yOffset +
118
- dayInfo.row * dayHeight +
119
- (entry.bucket * bucketMinutes * dayHeight) / 1440 -
120
- scrollTop;
121
-
122
- if (y > height || y + minHeight < 0) continue;
123
-
124
- ctx.globalAlpha = alpha;
125
- ctx.fillStyle = entry.color;
126
- ctx.fillRect(x, y, dayWidth - 2, minHeight);
127
- }
128
-
129
- ctx.globalAlpha = 1;
130
- },
131
- };
18
+ return {
19
+ name: "timeseries-heatmap",
20
+ enabled: true,
21
+ render(lc: LayerContext): void {
22
+ const {
23
+ ctx,
24
+ height,
25
+ scrollTop,
26
+ scrollLeft,
27
+ dayWidth,
28
+ dayHeight,
29
+ leftGutterWidth,
30
+ visibleWeeks,
31
+ allWeeks,
32
+ getDayVisualPosition,
33
+ } = lc;
34
+
35
+ const events = state.events.filter(
36
+ (event) => event.visualStyle === "heatmap",
37
+ );
38
+ if (events.length === 0 || visibleWeeks.length === 0) return;
39
+
40
+ const dayInfoMap = new Map<
41
+ number,
42
+ {
43
+ week: WeekInfo;
44
+ weekIndex: number;
45
+ dayIndex: number;
46
+ row: number;
47
+ col: number;
48
+ dayStartMs: number;
49
+ }
50
+ >();
51
+
52
+ for (const week of visibleWeeks) {
53
+ const weekIndex = allWeeks.indexOf(week);
54
+ if (weekIndex < 0) continue;
55
+ for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
56
+ const day = week.days[dayIndex];
57
+ if (!day) continue;
58
+ const dayStart = new Date(day);
59
+ dayStart.setHours(0, 0, 0, 0);
60
+ const { row, col } = getDayVisualPosition(dayIndex);
61
+ dayInfoMap.set(dayStart.getTime(), {
62
+ week,
63
+ weekIndex,
64
+ dayIndex,
65
+ row,
66
+ col,
67
+ dayStartMs: dayStart.getTime(),
68
+ });
69
+ }
70
+ }
71
+
72
+ if (dayInfoMap.size === 0) return;
73
+
74
+ const bucketMinutes = getBucketMinutes(dayHeight);
75
+ const bucketCounts = new Map<
76
+ string,
77
+ { count: number; color: string; dayKey: number; bucket: number }
78
+ >();
79
+ let maxCount = 0;
80
+
81
+ for (const event of events) {
82
+ const start = event.start;
83
+ const dayStart = new Date(start);
84
+ dayStart.setHours(0, 0, 0, 0);
85
+ const dayKey = dayStart.getTime();
86
+ const dayInfo = dayInfoMap.get(dayKey);
87
+ if (!dayInfo) continue;
88
+
89
+ const minutesIntoDay = (start.getTime() - dayInfo.dayStartMs) / 60000;
90
+ if (minutesIntoDay < 0 || minutesIntoDay >= 1440) continue;
91
+
92
+ const bucket = Math.floor(minutesIntoDay / bucketMinutes);
93
+ const color = event.color || "rgba(255, 255, 255, 0.6)";
94
+ const key = `${dayKey}-${bucket}-${color}`;
95
+ const entry = bucketCounts.get(key) || {
96
+ count: 0,
97
+ color,
98
+ dayKey,
99
+ bucket,
100
+ };
101
+ entry.count += 1;
102
+ bucketCounts.set(key, entry);
103
+ if (entry.count > maxCount) maxCount = entry.count;
104
+ }
105
+
106
+ if (maxCount === 0) return;
107
+
108
+ const bucketHeight = (bucketMinutes / 1440) * dayHeight;
109
+ const minHeight = Math.max(2, bucketHeight);
110
+
111
+ for (const entry of bucketCounts.values()) {
112
+ const dayInfo = dayInfoMap.get(entry.dayKey);
113
+ if (!dayInfo) continue;
114
+
115
+ const intensity = Math.log(entry.count + 1) / Math.log(maxCount + 1);
116
+ const alpha = 0.08 + intensity * 0.22;
117
+
118
+ const x = leftGutterWidth + dayInfo.col * dayWidth - scrollLeft + 1;
119
+ const y =
120
+ dayInfo.week.yOffset +
121
+ dayInfo.row * dayHeight +
122
+ (entry.bucket * bucketMinutes * dayHeight) / 1440 -
123
+ scrollTop;
124
+
125
+ if (y > height || y + minHeight < 0) continue;
126
+
127
+ ctx.globalAlpha = alpha;
128
+ ctx.fillStyle = entry.color;
129
+ ctx.fillRect(x, y, dayWidth - 2, minHeight);
130
+ }
131
+
132
+ ctx.globalAlpha = 1;
133
+ },
134
+ };
132
135
  }
@@ -109,8 +109,9 @@ async function getScheduledNotifications() {
109
109
  request.onsuccess = () => {
110
110
  const notifications = request.result || [];
111
111
  // Sort by trigger time
112
- notifications.sort((a, b) =>
113
- new Date(a.triggerTime).getTime() - new Date(b.triggerTime).getTime()
112
+ notifications.sort(
113
+ (a, b) =>
114
+ new Date(a.triggerTime).getTime() - new Date(b.triggerTime).getTime(),
114
115
  );
115
116
  resolve(notifications);
116
117
  };