@hearthlog/shell 0.0.0-stage → 0.1.1
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.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/app.template.html +184 -0
- package/src/components/ai/DecisionReviewSheet.svelte +226 -0
- package/src/components/ai/SuggestButton.svelte +23 -0
- package/src/components/ai/SuggestionChip.svelte +13 -0
- package/src/components/ai/SuggestionMark.svelte +15 -0
- package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
- package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
- package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
- package/src/components/calendar/source-icons.ts +19 -0
- package/src/components/data-table/DataTableValue.svelte +14 -0
- package/src/components/data-table/EntityActionsCell.svelte +36 -0
- package/src/components/data-table/EntityRefsCell.svelte +58 -0
- package/src/components/data-table/EntityValue.svelte +42 -0
- package/src/components/data-table/entity-columns.ts +87 -0
- package/src/components/data-table/sqlite-columns.ts +42 -0
- package/src/components/entities/CalendarSourceFields.svelte +149 -0
- package/src/components/history/ChangeRecord.svelte +30 -0
- package/src/components/history/ForkComposer.svelte +174 -0
- package/src/components/history/HistoryScrubber.svelte +245 -0
- package/src/components/history/LogHistory.svelte +334 -0
- package/src/components/history/LogMap.svelte +293 -0
- package/src/components/history/LogTree.svelte +124 -0
- package/src/components/layout/AccountMenu.svelte +111 -0
- package/src/components/layout/AppearanceOptions.svelte +78 -0
- package/src/components/layout/BrandWordmark.svelte +12 -0
- package/src/components/layout/ErrorToast.svelte +34 -0
- package/src/components/layout/LogCreateDrawer.svelte +111 -0
- package/src/components/layout/LogPanel.svelte +225 -0
- package/src/components/layout/LogStatusChip.svelte +137 -0
- package/src/components/layout/MobilePageAction.svelte +44 -0
- package/src/components/layout/MovedBanner.svelte +51 -0
- package/src/components/layout/NavBar.svelte +603 -0
- package/src/components/layout/NavFlyout.svelte +132 -0
- package/src/components/layout/NavItem.svelte +36 -0
- package/src/components/layout/NavMoreDialog.svelte +179 -0
- package/src/components/layout/PageDataStatus.svelte +36 -0
- package/src/components/layout/PhoneGlance.svelte +96 -0
- package/src/components/layout/SidebarResizer.svelte +122 -0
- package/src/components/layout/SyncIndicator.svelte +24 -0
- package/src/components/layout/feature-icons.ts +24 -0
- package/src/components/layout/nav-indicator.ts +128 -0
- package/src/components/layout/sidebar-size.ts +31 -0
- package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
- package/src/components/sync/SyncStateBadge.svelte +17 -0
- package/src/pages/calendar/Page.svelte +1341 -0
- package/src/pages/calendar/load.ts +77 -0
- package/src/pages/developer/DatabaseBrowser.svelte +325 -0
- package/src/pages/developer/Event.svelte +51 -0
- package/src/pages/developer/EventLogManager.svelte +268 -0
- package/src/pages/developer/IndexedDbStats.svelte +93 -0
- package/src/pages/developer/LedgerAdmin.svelte +877 -0
- package/src/pages/developer/LocalSqliteStats.svelte +56 -0
- package/src/pages/developer/Page.svelte +590 -0
- package/src/pages/developer/PendingWorkImport.svelte +49 -0
- package/src/pages/developer/PerformanceDashboard.svelte +262 -0
- package/src/pages/developer/StackInfo.svelte +117 -0
- package/src/pages/developer/SyncLog.svelte +188 -0
- package/src/pages/developer/load.ts +28 -0
- package/src/pages/entities/Page.svelte +1069 -0
- package/src/pages/entities/load.ts +18 -0
- package/src/pages/entity/Page.svelte +655 -0
- package/src/pages/entity/load.ts +79 -0
- package/src/pages/error/ErrorPage.svelte +147 -0
- package/src/pages/layout/Layout.svelte +597 -0
- package/src/pages/layout/load.ts +28 -0
- package/src/pages/redirects/history.ts +20 -0
- package/src/pages/redirects/start.ts +11 -0
- package/src/pages/redirects/today.ts +9 -0
- package/src/service-worker/index.ts +108 -0
- package/src/service-worker/offline-bundle.ts +116 -0
- package/src/service-worker/virtual.d.ts +3 -0
- package/src/styles/index.css +11 -0
- package/src/styles/shell.css +296 -0
- package/static/_headers +4 -0
- package/static/favicon.ico +0 -0
- package/static/offline.html +44 -0
- package/static/replay/sw.js +2 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
const drawerId = $props.id();
|
|
3
|
+
const drawerFormId0 = drawerId + "-0";
|
|
4
|
+
|
|
5
|
+
import { providePageResource } from "@hearthlog/runtime/runtime/page-data.svelte.js";
|
|
6
|
+
import PageDataStatus from "@hearthlog/shell/components/layout/PageDataStatus.svelte";
|
|
7
|
+
import DataPlaceholder from "@hearthlog/ui/components/data/DataPlaceholder.svelte";
|
|
8
|
+
import { goto } from "$app/navigation";
|
|
9
|
+
import { page } from "$app/state";
|
|
10
|
+
import { refreshPageData as invalidate } from "@hearthlog/runtime/runtime/refresh.js";
|
|
11
|
+
import { onMount, tick, untrack } from "svelte";
|
|
12
|
+
import { Popover } from "bits-ui";
|
|
13
|
+
import { format as formatDay } from "date-fns";
|
|
14
|
+
import {
|
|
15
|
+
calendarMonthBounds,
|
|
16
|
+
describeEventCountdown,
|
|
17
|
+
daysUntilCalendarEvent,
|
|
18
|
+
parseCalendarMonth,
|
|
19
|
+
toLocalDateString,
|
|
20
|
+
validateCalendarEventDraft,
|
|
21
|
+
type CalendarEventDraft,
|
|
22
|
+
} from "@hearthlog/runtime/calendar/logic.js";
|
|
23
|
+
import { countWorkingDays, getWorkingDays, type DateInfo } from "@hearthlog/czech";
|
|
24
|
+
import {
|
|
25
|
+
calendarSourcePresentation,
|
|
26
|
+
calendarItemHref,
|
|
27
|
+
type CalendarTone,
|
|
28
|
+
} from "@hearthlog/contracts/calendar/sources";
|
|
29
|
+
import {
|
|
30
|
+
calendarMenuEntries,
|
|
31
|
+
effectiveCalendarSourcePreference,
|
|
32
|
+
isCalendarMenuEntryVisible,
|
|
33
|
+
toggleCalendarMenuEntry,
|
|
34
|
+
type CalendarSourcePreference,
|
|
35
|
+
} from "@hearthlog/runtime/calendar/groups.js";
|
|
36
|
+
import { setCalendarSourcePreference } from "@hearthlog/runtime/calendar/preferences.js";
|
|
37
|
+
import { eventLogFilename } from "@hearthlog/runtime/store/event-log.svelte.js";
|
|
38
|
+
import { serializeIcs, type IcsEventInput } from "@hearthlog/contracts/calendar/ics";
|
|
39
|
+
import type {
|
|
40
|
+
CalendarPointItem,
|
|
41
|
+
CalendarRangeItem,
|
|
42
|
+
CalendarRangeResult,
|
|
43
|
+
CalendarSourceCounts,
|
|
44
|
+
CalendarSourceInfo,
|
|
45
|
+
CalendarWeek,
|
|
46
|
+
} from "@hearthlog/contracts/calendar/types";
|
|
47
|
+
import { dayColumn, monthEdges, monthInView, WEEK_SPAN } from "@hearthlog/runtime/calendar/weeks.js";
|
|
48
|
+
import { monthKeyOf, shiftDay, shiftMonthKey, shiftWeek, weekAxis, weekStartOf } from "@hearthlog/ui/utils/endless-axis.js";
|
|
49
|
+
import EndlessList, { type EndlessBlock, type EndlessPosition, type EndlessTarget, type EndlessView } from "@hearthlog/ui/components/endless/EndlessList.svelte";
|
|
50
|
+
import { CalendarWeeks } from "@hearthlog/shell/components/calendar/calendar-weeks.svelte.js";
|
|
51
|
+
import { calendarSourceIcon } from "@hearthlog/shell/components/calendar/source-icons.js";
|
|
52
|
+
import CalendarFeedsDrawer from "@hearthlog/shell/components/calendar/CalendarFeedsDrawer.svelte";
|
|
53
|
+
import CalendarMonthSummary from "@hearthlog/shell/components/calendar/CalendarMonthSummary.svelte";
|
|
54
|
+
import type { CalendarEvent } from "@hearthlog/runtime/db/calendar.js";
|
|
55
|
+
import {
|
|
56
|
+
type AddCalendarEventEvent,
|
|
57
|
+
type RemoveCalendarEventEvent,
|
|
58
|
+
type UpdateCalendarEventEvent,
|
|
59
|
+
} from "@hearthlog/runtime/event/index.js";
|
|
60
|
+
import { executeLocalCommand } from "@hearthlog/runtime/commands/index.js";
|
|
61
|
+
import { queryDatabase } from "@hearthlog/runtime/db/read-repositories.js";
|
|
62
|
+
import { notify } from "@hearthlog/ui/state/notifications.svelte.js";
|
|
63
|
+
import { contextualHref } from "@hearthlog/runtime/runtime/view.js";
|
|
64
|
+
import { tenant } from "@hearthlog/contracts/tenant/config";
|
|
65
|
+
import { runCommandAction as runWorkspaceCommandAction } from "@hearthlog/ui/state/commands.js";
|
|
66
|
+
import CommandOnly from "@hearthlog/ui/components/commands/CommandOnly.svelte";
|
|
67
|
+
import CommandDrawer from "@hearthlog/ui/components/commands/CommandDrawer.svelte";
|
|
68
|
+
import CommandConfirmDialog from "@hearthlog/ui/components/commands/CommandConfirmDialog.svelte";
|
|
69
|
+
import Button from "@hearthlog/ui/components/ui/Button.svelte";
|
|
70
|
+
import MonthNavigator from "@hearthlog/ui/components/ui/MonthNavigator.svelte";
|
|
71
|
+
import MobilePageAction from "@hearthlog/shell/components/layout/MobilePageAction.svelte";
|
|
72
|
+
import Checkbox from "@hearthlog/ui/components/ui/Checkbox.svelte";
|
|
73
|
+
import DatePicker from "@hearthlog/ui/components/ui/DatePicker.svelte";
|
|
74
|
+
import Field from "@hearthlog/ui/components/ui/Field.svelte";
|
|
75
|
+
import Input from "@hearthlog/ui/components/ui/Input.svelte";
|
|
76
|
+
import Textarea from "@hearthlog/ui/components/ui/Textarea.svelte";
|
|
77
|
+
import PageHeader from "@hearthlog/ui/components/PageHeader.svelte";
|
|
78
|
+
import IconDelete from "~icons/mdi/trash-can-outline";
|
|
79
|
+
import IconPin from "~icons/mdi/pin";
|
|
80
|
+
import IconPlus from "~icons/mdi/plus";
|
|
81
|
+
import IconExport from "~icons/mdi/download-outline";
|
|
82
|
+
import IconRss from "~icons/mdi/rss";
|
|
83
|
+
import IconChevronDown from "~icons/mdi/chevron-down";
|
|
84
|
+
import IconFilter from "~icons/mdi/filter-variant";
|
|
85
|
+
import IconClose from "~icons/mdi/close";
|
|
86
|
+
import { MediaQuery } from "svelte/reactivity";
|
|
87
|
+
import PhoneGlance from "@hearthlog/shell/components/layout/PhoneGlance.svelte";
|
|
88
|
+
import Switch from "@hearthlog/ui/components/ui/Switch.svelte";
|
|
89
|
+
|
|
90
|
+
let { data: routeData } = $props();
|
|
91
|
+
const resource = $derived(routeData.pageResource);
|
|
92
|
+
const liveData = $derived(resource.data);
|
|
93
|
+
// Keep the loaded data on screen while a refresh loads, so saves and
|
|
94
|
+
// filter changes swap atomically instead of flashing skeletons.
|
|
95
|
+
// PageDataStatus keeps the real resource for perf recording.
|
|
96
|
+
let displayedData = $state<typeof liveData | null>(null);
|
|
97
|
+
// Show skeletons only for the first paint of this instance.
|
|
98
|
+
let hasPaintedMonth = $state(false);
|
|
99
|
+
/** Counts applied loads; the weeks on screen read again when it changes. */
|
|
100
|
+
let revision = $state(0);
|
|
101
|
+
$effect(() => {
|
|
102
|
+
if (resource.loading) return;
|
|
103
|
+
const next = liveData;
|
|
104
|
+
untrack(() => applyData(next));
|
|
105
|
+
});
|
|
106
|
+
const data = $derived(displayedData ?? liveData);
|
|
107
|
+
const displayResource = $derived({
|
|
108
|
+
loading: hasPaintedMonth ? false : resource.loading,
|
|
109
|
+
error: resource.error,
|
|
110
|
+
measurement: resource.measurement,
|
|
111
|
+
});
|
|
112
|
+
providePageResource(() => displayResource);
|
|
113
|
+
function runCommandAction(action: () => void): boolean {
|
|
114
|
+
return !displayResource.loading && runWorkspaceCommandAction(action);
|
|
115
|
+
}
|
|
116
|
+
const boundaryNow = $derived(new Date(data.effectiveNow));
|
|
117
|
+
|
|
118
|
+
const monthNames = [
|
|
119
|
+
"January", "February", "March", "April", "May", "June",
|
|
120
|
+
"July", "August", "September", "October", "November", "December",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
let calendarControl: HTMLElement | null = $state(null);
|
|
124
|
+
let drawerOpen = $state(false);
|
|
125
|
+
let editingId = $state<string | null>(null);
|
|
126
|
+
let editingEvent = $state<CalendarEvent | null>(null);
|
|
127
|
+
let saving = $state(false);
|
|
128
|
+
let pinningId = $state<string | null>(null);
|
|
129
|
+
let deleteId = $state<string | null>(null);
|
|
130
|
+
let feedsOpen = $state(false);
|
|
131
|
+
let errors = $state<Record<string, string>>({});
|
|
132
|
+
let draft = $state<CalendarEventDraft>({
|
|
133
|
+
title: "",
|
|
134
|
+
eventDate: "",
|
|
135
|
+
startTime: "",
|
|
136
|
+
notes: "",
|
|
137
|
+
pinned: false,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const todayMonth = $derived(monthKeyOf(data.today));
|
|
141
|
+
/** The month in view; the header, the summary and the address follow it. */
|
|
142
|
+
let visibleMonth = $state("");
|
|
143
|
+
const shownMonth = $derived(visibleMonth || data.month.key);
|
|
144
|
+
const currentYear = $derived(Number(shownMonth.slice(0, 4)));
|
|
145
|
+
const currentMonth = $derived(Number(shownMonth.slice(5, 7)) - 1);
|
|
146
|
+
const sources = $derived(data.sources as CalendarSourceInfo[]);
|
|
147
|
+
const sourceByKey = $derived(new Map(sources.map((source) => [source.key, source])));
|
|
148
|
+
let pendingPreference = $state<CalendarSourcePreference | null>(null);
|
|
149
|
+
let preferenceSaving = $state(false);
|
|
150
|
+
const storedPreference = $derived(
|
|
151
|
+
data.sourcePreference as CalendarSourcePreference | null,
|
|
152
|
+
);
|
|
153
|
+
const activePreference = $derived(pendingPreference ?? storedPreference);
|
|
154
|
+
const sourceMenuEntries = $derived(calendarMenuEntries(sources));
|
|
155
|
+
const effectivePreference = $derived(
|
|
156
|
+
effectiveCalendarSourcePreference(sources, activePreference),
|
|
157
|
+
);
|
|
158
|
+
const hiddenSourceCount = $derived(
|
|
159
|
+
sourceMenuEntries.filter(
|
|
160
|
+
(entry) => !isCalendarMenuEntryVisible(entry, effectivePreference),
|
|
161
|
+
).length,
|
|
162
|
+
);
|
|
163
|
+
const showSourceMenu = $derived(sourceMenuEntries.length > 1);
|
|
164
|
+
const pinnedEvents = $derived(data.pinnedEvents as CalendarEvent[]);
|
|
165
|
+
const workingDayCount = $derived(countWorkingDays(currentYear, currentMonth));
|
|
166
|
+
const monthLabel = $derived(`${monthNames[currentMonth]} ${currentYear}`);
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------- endless weeks
|
|
169
|
+
|
|
170
|
+
// Weeks are counted from the week the page opened in. Their keys are
|
|
171
|
+
// Mondays, so a week keeps its place however long the page stays open.
|
|
172
|
+
const axis = weekAxis(weekStartOf(untrack(() => data.today) || toLocalDateString()), WEEK_SPAN);
|
|
173
|
+
/** A week before it is measured; days list their items on desktop. */
|
|
174
|
+
const weekEstimate = typeof window === "undefined" ? 62
|
|
175
|
+
: window.matchMedia("(min-width: 1024px)").matches ? 132
|
|
176
|
+
: window.matchMedia("(max-width: 767px)").matches ? 106 : 62;
|
|
177
|
+
// Phones: the month grid is the page; a tapped day opens a card above the dock.
|
|
178
|
+
const phone = new MediaQuery("max-width: 767px");
|
|
179
|
+
// Below 1024 px the header has no Feeds and Export buttons; the Sources menu carries them.
|
|
180
|
+
const compactHeader = new MediaQuery("max-width: 1023px");
|
|
181
|
+
let peekOpen = $state(false);
|
|
182
|
+
let eventNotesOpen = $state(false);
|
|
183
|
+
$effect(() => { if (!drawerOpen) eventNotesOpen = false; });
|
|
184
|
+
const weeks = new CalendarWeeks(() => storedPreference);
|
|
185
|
+
let list = $state<ReturnType<typeof EndlessList>>();
|
|
186
|
+
|
|
187
|
+
/** A month opens with its first week at the top. */
|
|
188
|
+
function monthTarget(month: string): EndlessTarget {
|
|
189
|
+
return { key: weekStartOf(`${month}-01`), at: month };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Today settles a week below the top, so the days before it stay in sight. */
|
|
193
|
+
const todayTarget = $derived<EndlessTarget & { label: string }>({
|
|
194
|
+
key: shiftWeek(weekStartOf(data.today), -1),
|
|
195
|
+
at: todayMonth,
|
|
196
|
+
find: (block) => block.parentElement?.querySelector<HTMLElement>(`[data-calendar-day="${data.today}"]`) ?? null,
|
|
197
|
+
ratio: 0.5,
|
|
198
|
+
label: "Today",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Weeks whose middle is on screen count, and the month holding most of
|
|
203
|
+
* their days is the one in view, as it would be on a month page.
|
|
204
|
+
*/
|
|
205
|
+
function pick(blocks: EndlessBlock[], { edge, bottom }: EndlessView): EndlessPosition {
|
|
206
|
+
const counted = blocks.filter(({ element }) => {
|
|
207
|
+
if (!element) return true;
|
|
208
|
+
const rect = element.getBoundingClientRect();
|
|
209
|
+
const middle = (rect.top + rect.bottom) / 2;
|
|
210
|
+
return middle >= edge && middle <= bottom;
|
|
211
|
+
});
|
|
212
|
+
const shown = (counted.length > 0 ? counted : blocks).map(({ key }) => key);
|
|
213
|
+
return { key: shown[0], at: monthInView(shown) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function weekDays(week: string): string[] {
|
|
217
|
+
return Array.from({ length: 7 }, (_, offset) => shiftDay(week, offset));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Working days and holidays, worked out once per month. */
|
|
221
|
+
const monthDays = new Map<string, DateInfo[]>();
|
|
222
|
+
function dayInfo(day: string): DateInfo {
|
|
223
|
+
const month = monthKeyOf(day);
|
|
224
|
+
let days = monthDays.get(month);
|
|
225
|
+
if (!days) {
|
|
226
|
+
days = getWorkingDays(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1);
|
|
227
|
+
monthDays.set(month, days);
|
|
228
|
+
}
|
|
229
|
+
return days[Number(day.slice(8, 10)) - 1];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function applyData(next: typeof liveData): void {
|
|
233
|
+
const previous = displayedData;
|
|
234
|
+
displayedData = next;
|
|
235
|
+
hasPaintedMonth = true;
|
|
236
|
+
revision += 1;
|
|
237
|
+
counts = { month: next.month.key, revision, value: next.monthCounts as CalendarSourceCounts };
|
|
238
|
+
if (!previous) {
|
|
239
|
+
// A month chosen while the page was loading wins over the address.
|
|
240
|
+
if (!visibleMonth) visibleMonth = next.month.key;
|
|
241
|
+
if (!selectedDate) selectedDate = visibleMonth === monthKeyOf(next.today) ? next.today : `${visibleMonth}-01`;
|
|
242
|
+
} else if (previous.month.key !== next.month.key && next.month.key !== visibleMonth) {
|
|
243
|
+
// A real navigation to another month while the grid is open.
|
|
244
|
+
void tick().then(() => goToMonth(next.month.key));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The summary counts the month in view in SQL; a load brings the month it opened at.
|
|
249
|
+
let counts = $state.raw<{ month: string; revision: number; value: CalendarSourceCounts } | null>(null);
|
|
250
|
+
$effect(() => {
|
|
251
|
+
const month = shownMonth;
|
|
252
|
+
const current = revision;
|
|
253
|
+
const preference = storedPreference;
|
|
254
|
+
if (current === 0 || counts && counts.month === month && counts.revision === current) return;
|
|
255
|
+
let active = true;
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
const bounds = calendarMonthBounds(parseCalendarMonth(month));
|
|
258
|
+
void queryDatabase((repositories) =>
|
|
259
|
+
repositories.calendar.getCalendarSourceCounts(bounds.startDate, bounds.endDateExclusive, preference),
|
|
260
|
+
).then((value) => {
|
|
261
|
+
if (active) counts = { month, revision: current, value };
|
|
262
|
+
}).catch((error) => console.warn("[Calendar] Could not count the month", error));
|
|
263
|
+
}, 150);
|
|
264
|
+
return () => {
|
|
265
|
+
active = false;
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
const monthCounts = $derived(counts?.value ?? { total: 0, sources: [] });
|
|
270
|
+
const countsLoading = $derived(displayResource.loading || counts?.month !== shownMonth);
|
|
271
|
+
|
|
272
|
+
const TONE_CLASSES: Record<CalendarTone, string> = {
|
|
273
|
+
accent: "bg-[var(--stor-accent-soft)] text-[var(--stor-ink)]",
|
|
274
|
+
warning: "bg-[var(--stor-warning-soft)] text-[var(--stor-ink)]",
|
|
275
|
+
negative: "bg-[var(--stor-negative-soft)] text-[var(--stor-ink)]",
|
|
276
|
+
positive: "bg-[var(--stor-positive-soft)] text-[var(--stor-ink)]",
|
|
277
|
+
info: "bg-[var(--stor-info-soft)] text-[var(--stor-ink)]",
|
|
278
|
+
muted: "bg-[var(--stor-surface-muted)] text-[var(--stor-ink)]",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const TONE_DOTS: Record<CalendarTone, string> = {
|
|
282
|
+
accent: "bg-[var(--stor-accent)]",
|
|
283
|
+
warning: "bg-[var(--stor-warning)]",
|
|
284
|
+
negative: "bg-[var(--stor-negative)]",
|
|
285
|
+
positive: "bg-[var(--stor-positive)]",
|
|
286
|
+
info: "bg-[var(--stor-info)]",
|
|
287
|
+
muted: "bg-[var(--stor-muted-text)]",
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
function emptyDraft(eventDate: string): CalendarEventDraft {
|
|
291
|
+
return { title: "", eventDate, startTime: "", notes: "", pinned: false };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function focusCalendar(): Promise<void> {
|
|
295
|
+
await tick();
|
|
296
|
+
calendarControl?.focus({ preventScroll: true });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The address follows the month in view quietly, so a reload opens there.
|
|
300
|
+
let addressTimer: ReturnType<typeof setTimeout> | undefined;
|
|
301
|
+
function followVisible(month: string): void {
|
|
302
|
+
if (!month) return;
|
|
303
|
+
visibleMonth = month;
|
|
304
|
+
clearTimeout(addressTimer);
|
|
305
|
+
addressTimer = setTimeout(() => {
|
|
306
|
+
const url = new URL(window.location.href);
|
|
307
|
+
if ((url.searchParams.get("month") ?? todayMonth) === month) return;
|
|
308
|
+
url.searchParams.set("month", month);
|
|
309
|
+
goto(`${url.pathname}${url.search}${url.hash}`, { shallow: true, replace: true });
|
|
310
|
+
},
|
|
311
|
+
250
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
$effect(() => () => clearTimeout(addressTimer));
|
|
316
|
+
|
|
317
|
+
/** Scrolls to a month and selects today there, or its first day. */
|
|
318
|
+
function goToMonth(month: string): void {
|
|
319
|
+
followVisible(month);
|
|
320
|
+
selectedDate = month === todayMonth ? data.today : `${month}-01`;
|
|
321
|
+
list?.scrollTo(monthTarget(month));
|
|
322
|
+
void focusCalendar();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function goToToday(): void {
|
|
326
|
+
followVisible(todayMonth);
|
|
327
|
+
selectedDate = data.today;
|
|
328
|
+
list?.scrollTo(todayTarget);
|
|
329
|
+
void focusCalendar();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Spatial spec: explicit selected-day state. Moving to a month selects
|
|
333
|
+
// today there or its first day; scrolling by hand keeps the selection.
|
|
334
|
+
let selectedDate = $state<string>("");
|
|
335
|
+
const selectedWeek = $derived(selectedDate ? weeks.get(weekStartOf(selectedDate)) : undefined);
|
|
336
|
+
const selectedDay = $derived(selectedWeek?.days[dayColumn(selectedDate)]);
|
|
337
|
+
const selectedDayItems = $derived(selectedDay?.items ?? []);
|
|
338
|
+
const selectedDayRanges = $derived(
|
|
339
|
+
selectedWeek && selectedDay ? selectedDay.lanes.map((lane) => selectedWeek.bands[lane].range) : [],
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// The selected day's week stays loaded while it is off screen.
|
|
343
|
+
$effect(() => {
|
|
344
|
+
if (!selectedDate || !hasPaintedMonth) return;
|
|
345
|
+
void weeks.buckets.size;
|
|
346
|
+
weeks.ensure([weekStartOf(selectedDate)]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const selectedDayKind = $derived.by(() => {
|
|
350
|
+
if (!selectedDate) return "";
|
|
351
|
+
const info = dayInfo(selectedDate);
|
|
352
|
+
if (info.holidayName) return info.holidayName;
|
|
353
|
+
if (info.isWeekend) return "Weekend";
|
|
354
|
+
return `Working day ${info.workingDayNumber} of ${countWorkingDays(info.date.getFullYear(), info.date.getMonth())}`;
|
|
355
|
+
});
|
|
356
|
+
const selectedDaySummary = $derived.by(() => {
|
|
357
|
+
const count = selectedDayItems.length + selectedDayRanges.length;
|
|
358
|
+
const parts = [count === 0 ? "No events scheduled" : `${count} scheduled`];
|
|
359
|
+
if (selectedDayKind) parts.unshift(selectedDayKind);
|
|
360
|
+
if (selectedDate === data.today) parts.push("Today");
|
|
361
|
+
return parts.join(" · ");
|
|
362
|
+
});
|
|
363
|
+
const selectedDateLabel = $derived.by(() => {
|
|
364
|
+
const parsed = new Date(`${selectedDate}T12:00:00`);
|
|
365
|
+
if (Number.isNaN(parsed.getTime())) return selectedDate;
|
|
366
|
+
return formatDay(parsed, "EEEE, MMMM d, yyyy");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function selectDay(dayKey: string): void {
|
|
370
|
+
selectedDate = dayKey;
|
|
371
|
+
if (phone.current) {
|
|
372
|
+
peekOpen = true;
|
|
373
|
+
void tick().then(() => revealDay(dayKey));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Scrolls the grid so the chosen day sits clear of the card. */
|
|
378
|
+
function revealDay(dayKey: string): void {
|
|
379
|
+
const cell = document.querySelector<HTMLElement>(`[data-calendar-day="${dayKey}"]`);
|
|
380
|
+
const scroller = document.querySelector<HTMLElement>("[data-calendar-scroll]");
|
|
381
|
+
const card = document.querySelector<HTMLElement>("[data-calendar-peek]");
|
|
382
|
+
if (!cell || !scroller || !card) return;
|
|
383
|
+
const overlap = cell.getBoundingClientRect().bottom - card.getBoundingClientRect().top + 8;
|
|
384
|
+
if (overlap > 0) scroller.scrollBy({ top: overlap, behavior: "smooth" });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The next pinned event, for the phone top bar. */
|
|
388
|
+
const nextPinned = $derived(pinnedEvents.find((event) => event.event_date >= data.today) ?? null);
|
|
389
|
+
|
|
390
|
+
function defaultEventDate(): string {
|
|
391
|
+
return selectedDate || data.today;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function prevMonth(): void {
|
|
395
|
+
goToMonth(shiftMonthKey(shownMonth, -1));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function nextMonth(): void {
|
|
399
|
+
goToMonth(shiftMonthKey(shownMonth, 1));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function chooseMonth(year: number, month: number): void {
|
|
403
|
+
goToMonth(`${year}-${String(month).padStart(2, "0")}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function resetToToday(): void {
|
|
407
|
+
goToToday();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function showAllSources(): void {
|
|
411
|
+
if (hiddenSourceCount === 0) return;
|
|
412
|
+
void saveSourcePreference({ hiddenGroups: [], hiddenSources: [] });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function toggleSourceEntry(entryId: string): Promise<void> {
|
|
416
|
+
if (preferenceSaving) return;
|
|
417
|
+
const next = toggleCalendarMenuEntry(sources, activePreference, entryId);
|
|
418
|
+
if (!next) return;
|
|
419
|
+
await saveSourcePreference(next);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function saveSourcePreference(
|
|
423
|
+
next: CalendarSourcePreference,
|
|
424
|
+
): Promise<void> {
|
|
425
|
+
const logId = eventLogFilename.value;
|
|
426
|
+
if (!logId) return;
|
|
427
|
+
preferenceSaving = true;
|
|
428
|
+
pendingPreference = next;
|
|
429
|
+
try {
|
|
430
|
+
await setCalendarSourcePreference(logId, next);
|
|
431
|
+
await invalidate("app:calendar");
|
|
432
|
+
pendingPreference = null;
|
|
433
|
+
} catch (cause) {
|
|
434
|
+
pendingPreference = null;
|
|
435
|
+
notify.error(
|
|
436
|
+
cause instanceof Error
|
|
437
|
+
? cause.message
|
|
438
|
+
: "Could not save the calendar filter",
|
|
439
|
+
);
|
|
440
|
+
} finally {
|
|
441
|
+
preferenceSaving = false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function dayLabel(day: DateInfo, items: CalendarPointItem[], dayRanges: CalendarRangeItem[]): string {
|
|
446
|
+
const labels = [formatDay(day.date, "EEEE, MMMM d, yyyy")];
|
|
447
|
+
if (toLocalDateString(day.date) === data.today) labels.push("today");
|
|
448
|
+
if (day.holidayName) labels.push(`public holiday, ${day.holidayName}`);
|
|
449
|
+
else if (day.isWeekend) labels.push("weekend");
|
|
450
|
+
else labels.push("working day");
|
|
451
|
+
const count = items.length + dayRanges.length;
|
|
452
|
+
if (count > 0) {
|
|
453
|
+
labels.push(`${count} ${count === 1 ? "item" : "items"}`);
|
|
454
|
+
}
|
|
455
|
+
return labels.join(", ");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function rangeAriaLabel(range: CalendarRangeItem): string {
|
|
459
|
+
const label = sourceByKey.get(range.sourceKey)?.label.toLowerCase() ?? range.sourceKey;
|
|
460
|
+
return `${range.title}, ${label}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function itemSourceLabel(item: CalendarPointItem): string {
|
|
464
|
+
return sourceByKey.get(item.sourceKey)?.label.toLowerCase() ?? item.sourceKey;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function itemAriaLabel(item: CalendarPointItem): string {
|
|
468
|
+
const time = item.startTime || "All day";
|
|
469
|
+
if (item.sourceKey === "event") {
|
|
470
|
+
return `${item.title}, ${time}${item.pinned ? ", pinned" : ""}`;
|
|
471
|
+
}
|
|
472
|
+
const subtitle = item.subtitle ? `, ${item.subtitle}` : "";
|
|
473
|
+
return `${item.title}, ${itemSourceLabel(item)}${subtitle}`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function itemHref(item: CalendarPointItem, dayKey: string): string | null {
|
|
477
|
+
const source = sourceByKey.get(item.sourceKey);
|
|
478
|
+
if (!source) return null;
|
|
479
|
+
const href = calendarItemHref({
|
|
480
|
+
sourceKey: item.sourceKey,
|
|
481
|
+
id: item.id,
|
|
482
|
+
date: dayKey,
|
|
483
|
+
entityName: source.entityName,
|
|
484
|
+
params: item.params,
|
|
485
|
+
});
|
|
486
|
+
return href ? contextualHref(href, page.url) : null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function rangeHref(range: CalendarRangeItem): string | null {
|
|
490
|
+
const source = sourceByKey.get(range.sourceKey);
|
|
491
|
+
if (!source) return null;
|
|
492
|
+
const href = calendarItemHref({
|
|
493
|
+
sourceKey: range.sourceKey,
|
|
494
|
+
id: range.id,
|
|
495
|
+
date: range.startDate,
|
|
496
|
+
entityName: source.entityName,
|
|
497
|
+
params: range.params,
|
|
498
|
+
});
|
|
499
|
+
return href ? contextualHref(href, page.url) : null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function openItem(item: CalendarPointItem): Promise<void> {
|
|
503
|
+
if (item.sourceKey === "event") {
|
|
504
|
+
const event = await queryDatabase((repositories) =>
|
|
505
|
+
repositories.calendar.getCalendarEvent(item.id),
|
|
506
|
+
);
|
|
507
|
+
if (event) startEdit(event);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const href = itemHref(item, selectedDate);
|
|
511
|
+
if (href) await goto(href);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function absoluteHref(href: string | null): string | undefined {
|
|
515
|
+
if (!href) return undefined;
|
|
516
|
+
const origin = typeof window === "undefined" ? "https://stor" : window.location.origin;
|
|
517
|
+
try {
|
|
518
|
+
return new URL(href, origin).href;
|
|
519
|
+
} catch {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The month in view, source-filtered, as ICS events. Export is
|
|
526
|
+
* intentionally a download; subscribed feeds are served by the Worker.
|
|
527
|
+
*/
|
|
528
|
+
function collectIcsEvents(range: CalendarRangeResult): IcsEventInput[] {
|
|
529
|
+
const events: IcsEventInput[] = [];
|
|
530
|
+
for (const day of range.days) {
|
|
531
|
+
for (const item of day.items) {
|
|
532
|
+
const source = sourceByKey.get(item.sourceKey);
|
|
533
|
+
events.push({
|
|
534
|
+
uid: `${item.sourceKey}-${item.id}`,
|
|
535
|
+
title: item.title,
|
|
536
|
+
description: [item.subtitle, item.startTime].filter(Boolean).join(" · ") || undefined,
|
|
537
|
+
url: absoluteHref(itemHref(item, day.day)),
|
|
538
|
+
category: source?.label,
|
|
539
|
+
startDate: day.day,
|
|
540
|
+
startTime: item.startTime || undefined,
|
|
541
|
+
endTime: item.endTime || undefined,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const item of range.ranges) {
|
|
546
|
+
const source = sourceByKey.get(item.sourceKey);
|
|
547
|
+
events.push({
|
|
548
|
+
uid: `${item.sourceKey}-${item.id}`,
|
|
549
|
+
title: item.title,
|
|
550
|
+
description: item.subtitle || undefined,
|
|
551
|
+
url: absoluteHref(rangeHref(item)),
|
|
552
|
+
category: source?.label,
|
|
553
|
+
startDate: item.startDate,
|
|
554
|
+
endDate: item.endDate,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return events;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let exporting = $state(false);
|
|
561
|
+
|
|
562
|
+
async function exportIcs(): Promise<void> {
|
|
563
|
+
if (exporting) return;
|
|
564
|
+
exporting = true;
|
|
565
|
+
const month = shownMonth;
|
|
566
|
+
try {
|
|
567
|
+
const bounds = calendarMonthBounds(parseCalendarMonth(month));
|
|
568
|
+
const range = await queryDatabase((repositories) =>
|
|
569
|
+
repositories.calendar.getCalendarRange(bounds.startDate, bounds.endDateExclusive, storedPreference),
|
|
570
|
+
);
|
|
571
|
+
const events = collectIcsEvents(range);
|
|
572
|
+
const content = serializeIcs(events, {
|
|
573
|
+
name: tenant.title,
|
|
574
|
+
uidDomain: typeof window === "undefined" ? "stor" : window.location.hostname,
|
|
575
|
+
});
|
|
576
|
+
const blob = new Blob([content], { type: "text/calendar;charset=utf-8" });
|
|
577
|
+
const url = URL.createObjectURL(blob);
|
|
578
|
+
const anchor = document.createElement("a");
|
|
579
|
+
anchor.href = url;
|
|
580
|
+
anchor.download = `stor-calendar-${month}.ics`;
|
|
581
|
+
document.body.appendChild(anchor);
|
|
582
|
+
anchor.click();
|
|
583
|
+
anchor.remove();
|
|
584
|
+
URL.revokeObjectURL(url);
|
|
585
|
+
notify.success(
|
|
586
|
+
`Exported ${events.length} ${events.length === 1 ? "item" : "items"}`,
|
|
587
|
+
);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
console.error("Failed to export the calendar:", error);
|
|
590
|
+
notify.error("Could not export the month");
|
|
591
|
+
} finally {
|
|
592
|
+
exporting = false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function startCreate(eventDate = defaultEventDate()): void {
|
|
597
|
+
editingId = null;
|
|
598
|
+
editingEvent = null;
|
|
599
|
+
draft = emptyDraft(eventDate);
|
|
600
|
+
errors = {};
|
|
601
|
+
drawerOpen = true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function startEdit(event: CalendarEvent): void {
|
|
605
|
+
editingId = event.id;
|
|
606
|
+
editingEvent = event;
|
|
607
|
+
draft = {
|
|
608
|
+
title: event.title,
|
|
609
|
+
eventDate: event.event_date,
|
|
610
|
+
startTime: event.start_time,
|
|
611
|
+
notes: event.notes,
|
|
612
|
+
pinned: Boolean(event.pinned),
|
|
613
|
+
};
|
|
614
|
+
errors = {};
|
|
615
|
+
drawerOpen = true;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function eventPayload() {
|
|
619
|
+
return {
|
|
620
|
+
title: draft.title.trim(),
|
|
621
|
+
eventDate: draft.eventDate,
|
|
622
|
+
startTime: draft.startTime,
|
|
623
|
+
notes: draft.notes.trim(),
|
|
624
|
+
pinned: draft.pinned,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function saveEvent(submitEvent: SubmitEvent): Promise<void> {
|
|
629
|
+
submitEvent.preventDefault();
|
|
630
|
+
if (saving) return;
|
|
631
|
+
errors = validateCalendarEventDraft(draft);
|
|
632
|
+
if (Object.keys(errors).length > 0) return;
|
|
633
|
+
saving = true;
|
|
634
|
+
try {
|
|
635
|
+
if (editingId) {
|
|
636
|
+
const updateEvent: UpdateCalendarEventEvent = {
|
|
637
|
+
id: crypto.randomUUID(),
|
|
638
|
+
type: "CALENDAR_EVENT_UPDATED",
|
|
639
|
+
timestamp: Date.now(),
|
|
640
|
+
payload: { id: editingId, ...eventPayload() },
|
|
641
|
+
};
|
|
642
|
+
await executeLocalCommand(updateEvent);
|
|
643
|
+
notify.success("Event updated");
|
|
644
|
+
} else {
|
|
645
|
+
const addEvent: AddCalendarEventEvent = {
|
|
646
|
+
id: crypto.randomUUID(),
|
|
647
|
+
type: "CALENDAR_EVENT_ADDED",
|
|
648
|
+
timestamp: Date.now(),
|
|
649
|
+
payload: { id: crypto.randomUUID(), ...eventPayload() },
|
|
650
|
+
};
|
|
651
|
+
await executeLocalCommand(addEvent);
|
|
652
|
+
notify.success("Event added");
|
|
653
|
+
}
|
|
654
|
+
drawerOpen = false;
|
|
655
|
+
await invalidate("app:calendar");
|
|
656
|
+
} catch (error) {
|
|
657
|
+
console.error("Failed to save calendar event:", error);
|
|
658
|
+
notify.error("Could not save the event");
|
|
659
|
+
} finally {
|
|
660
|
+
saving = false;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function togglePin(event: CalendarEvent): Promise<void> {
|
|
665
|
+
if (pinningId) return;
|
|
666
|
+
pinningId = event.id;
|
|
667
|
+
try {
|
|
668
|
+
const updateEvent: UpdateCalendarEventEvent = {
|
|
669
|
+
id: crypto.randomUUID(),
|
|
670
|
+
type: "CALENDAR_EVENT_UPDATED",
|
|
671
|
+
timestamp: Date.now(),
|
|
672
|
+
payload: { id: event.id, pinned: !Boolean(event.pinned) },
|
|
673
|
+
};
|
|
674
|
+
await executeLocalCommand(updateEvent);
|
|
675
|
+
await invalidate("app:calendar");
|
|
676
|
+
} catch (error) {
|
|
677
|
+
console.error("Failed to update calendar pin:", error);
|
|
678
|
+
notify.error("Could not update the pin");
|
|
679
|
+
} finally {
|
|
680
|
+
pinningId = null;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function requestDelete(id: string): void {
|
|
685
|
+
drawerOpen = false;
|
|
686
|
+
deleteId = id;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function removeEvent(): Promise<void> {
|
|
690
|
+
if (!deleteId) return;
|
|
691
|
+
const id = deleteId;
|
|
692
|
+
deleteId = null;
|
|
693
|
+
editingEvent = null;
|
|
694
|
+
try {
|
|
695
|
+
const removeEvent: RemoveCalendarEventEvent = {
|
|
696
|
+
id: crypto.randomUUID(),
|
|
697
|
+
type: "CALENDAR_EVENT_REMOVED",
|
|
698
|
+
timestamp: Date.now(),
|
|
699
|
+
payload: { id },
|
|
700
|
+
};
|
|
701
|
+
await executeLocalCommand(removeEvent);
|
|
702
|
+
notify.success("Event deleted");
|
|
703
|
+
await invalidate("app:calendar");
|
|
704
|
+
} catch (error) {
|
|
705
|
+
console.error("Failed to delete calendar event:", error);
|
|
706
|
+
notify.error("Could not delete the event");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function handleCalendarKeydown(event: KeyboardEvent): void {
|
|
711
|
+
if (event.target !== calendarControl) return;
|
|
712
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
713
|
+
event.preventDefault();
|
|
714
|
+
prevMonth();
|
|
715
|
+
} else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
716
|
+
event.preventDefault();
|
|
717
|
+
nextMonth();
|
|
718
|
+
} else if (event.key === "PageUp") {
|
|
719
|
+
event.preventDefault();
|
|
720
|
+
goToMonth(shiftMonthKey(shownMonth, -12));
|
|
721
|
+
} else if (event.key === "PageDown") {
|
|
722
|
+
event.preventDefault();
|
|
723
|
+
goToMonth(shiftMonthKey(shownMonth, 12));
|
|
724
|
+
} else if (event.key === "Home") {
|
|
725
|
+
event.preventDefault();
|
|
726
|
+
resetToToday();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const MAX_DAY_CHIPS = 2;
|
|
731
|
+
|
|
732
|
+
onMount(() => {
|
|
733
|
+
// A keyboard user lands in the grid; on a phone the focus ring would only frame it in red.
|
|
734
|
+
if (window.matchMedia("(pointer: fine)").matches) void focusCalendar();
|
|
735
|
+
});
|
|
736
|
+
</script>
|
|
737
|
+
|
|
738
|
+
<svelte:head><title>{monthLabel} | Calendar | stor</title></svelte:head>
|
|
739
|
+
<PageDataStatus resource={resource} />
|
|
740
|
+
|
|
741
|
+
{#snippet dayAgenda()}
|
|
742
|
+
{#if !selectedWeek}
|
|
743
|
+
<DataPlaceholder rows={2} label="Loading agenda" />
|
|
744
|
+
{:else if selectedDayItems.length === 0 && selectedDayRanges.length === 0}
|
|
745
|
+
<p class="rounded-xl bg-[var(--stor-surface-muted)] px-3 py-4 text-sm text-[var(--stor-muted-text)]">Nothing scheduled for this day. Select another day or add an event.</p>
|
|
746
|
+
{:else}
|
|
747
|
+
{#each selectedDayItems as item (item.sourceKey + item.id)}
|
|
748
|
+
{@const presentation = calendarSourcePresentation(item.sourceKey)}
|
|
749
|
+
{@const Icon = calendarSourceIcon(presentation.icon)}
|
|
750
|
+
{@const href = itemHref(item, selectedDate)}
|
|
751
|
+
{#if item.sourceKey === "event"}
|
|
752
|
+
<button
|
|
753
|
+
type="button"
|
|
754
|
+
onclick={() => runCommandAction(() => void openItem(item))}
|
|
755
|
+
class="flex w-full items-center gap-3 rounded-xl bg-transparent px-1 py-3 text-left transition hover:border-[var(--stor-field-border)]"
|
|
756
|
+
aria-label={itemAriaLabel(item)}
|
|
757
|
+
>
|
|
758
|
+
<Icon class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
759
|
+
<span class="min-w-0 flex-1">
|
|
760
|
+
<span class="block truncate text-sm font-semibold text-[var(--stor-ink)]">{item.title}</span>
|
|
761
|
+
<span class="mt-0.5 block text-xs text-[var(--stor-muted-text)]">{item.startTime || "All day"}{item.pinned ? " · Pinned" : ""}</span>
|
|
762
|
+
</span>
|
|
763
|
+
{#if item.pinned}<IconPin class="h-4 w-4 shrink-0 text-[var(--stor-accent-text)]" />{/if}
|
|
764
|
+
</button>
|
|
765
|
+
{:else if href}
|
|
766
|
+
<a
|
|
767
|
+
href={href}
|
|
768
|
+
class="flex w-full items-center gap-3 rounded-xl border border-[var(--stor-border)] px-3 py-2.5 text-left"
|
|
769
|
+
aria-label={itemAriaLabel(item)}
|
|
770
|
+
>
|
|
771
|
+
<Icon class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
772
|
+
<span class="min-w-0 flex-1">
|
|
773
|
+
<span class="block truncate text-sm font-semibold text-[var(--stor-ink)]">{item.title}</span>
|
|
774
|
+
<span class="mt-0.5 block text-xs text-[var(--stor-muted-text)]">{itemSourceLabel(item)}{item.subtitle ? ` · ${item.subtitle}` : ""}{item.startTime ? ` · ${item.startTime}` : ""}</span>
|
|
775
|
+
</span>
|
|
776
|
+
</a>
|
|
777
|
+
{/if}
|
|
778
|
+
{/each}
|
|
779
|
+
{#each selectedDayRanges as range (range.sourceKey + range.id)}
|
|
780
|
+
{@const presentation = calendarSourcePresentation(range.sourceKey)}
|
|
781
|
+
{@const Icon = calendarSourceIcon(presentation.icon)}
|
|
782
|
+
{@const href = rangeHref(range)}
|
|
783
|
+
{@const label = sourceByKey.get(range.sourceKey)?.label.toLowerCase() ?? range.sourceKey}
|
|
784
|
+
{#if href}
|
|
785
|
+
<a
|
|
786
|
+
href={href}
|
|
787
|
+
class="flex w-full items-center gap-3 rounded-xl border border-[var(--stor-border)] px-3 py-2.5 text-left"
|
|
788
|
+
aria-label={`${range.title}, ${label}`}
|
|
789
|
+
>
|
|
790
|
+
<Icon class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
791
|
+
<span class="min-w-0 flex-1">
|
|
792
|
+
<span class="block truncate text-sm font-semibold text-[var(--stor-ink)]">{range.title}</span>
|
|
793
|
+
<span class="mt-0.5 block text-xs text-[var(--stor-muted-text)]">{label} · {formatDay(new Date(`${range.startDate}T12:00:00`), "MMM d")} – {formatDay(new Date(`${range.endDate}T12:00:00`), "MMM d")}</span>
|
|
794
|
+
</span>
|
|
795
|
+
</a>
|
|
796
|
+
{/if}
|
|
797
|
+
{/each}
|
|
798
|
+
{/if}
|
|
799
|
+
{/snippet}
|
|
800
|
+
|
|
801
|
+
{#snippet pinnedPanel()}
|
|
802
|
+
<section class="stor-panel min-w-0 p-[var(--stor-panel-padding)]" aria-labelledby="pinned-events-heading">
|
|
803
|
+
<div class="flex items-center justify-between gap-3">
|
|
804
|
+
<div>
|
|
805
|
+
<h2 id="pinned-events-heading" class="font-semibold text-[var(--stor-ink)]">Pinned events</h2>
|
|
806
|
+
<p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">Important dates coming up</p>
|
|
807
|
+
</div>
|
|
808
|
+
<IconPin class="h-5 w-5 text-[var(--stor-accent-text)]" />
|
|
809
|
+
</div>
|
|
810
|
+
{#if displayResource.loading}<DataPlaceholder rows={2} />{:else if pinnedEvents.length > 0}
|
|
811
|
+
<div class="mt-3 space-y-4">
|
|
812
|
+
{#each pinnedEvents as event (event.id)}
|
|
813
|
+
<article class="min-w-0 py-2">
|
|
814
|
+
<div class="flex items-start justify-between gap-2">
|
|
815
|
+
<button
|
|
816
|
+
type="button"
|
|
817
|
+
class="min-w-0 flex-1 text-left"
|
|
818
|
+
onclick={() => runCommandAction(() => startEdit(event))}
|
|
819
|
+
>
|
|
820
|
+
<span class="block truncate text-sm font-semibold text-[var(--stor-ink)]">{event.title}</span>
|
|
821
|
+
<span class="mt-1 block text-xs text-[var(--stor-muted-text)]">
|
|
822
|
+
{formatDay(new Date(`${event.event_date}T12:00:00`), "MMM d, yyyy")} · {event.start_time || "All day"}
|
|
823
|
+
</span>
|
|
824
|
+
</button>
|
|
825
|
+
<CommandOnly>
|
|
826
|
+
<Button
|
|
827
|
+
variant="ghost"
|
|
828
|
+
size="icon"
|
|
829
|
+
class="-mr-1 -mt-1 h-8 w-8"
|
|
830
|
+
aria-label={`Unpin ${event.title}`}
|
|
831
|
+
disabled={pinningId === event.id}
|
|
832
|
+
onclick={() => togglePin(event)}
|
|
833
|
+
><IconPin class="h-4 w-4" /></Button>
|
|
834
|
+
</CommandOnly>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="mt-3 text-sm font-semibold text-[var(--stor-accent-strong)]">
|
|
837
|
+
{describeEventCountdown(daysUntilCalendarEvent(event.event_date, boundaryNow))}
|
|
838
|
+
</div>
|
|
839
|
+
</article>
|
|
840
|
+
{/each}
|
|
841
|
+
</div>
|
|
842
|
+
{:else}
|
|
843
|
+
<p class="mt-3 rounded-xl bg-[var(--stor-surface-muted)] px-3 py-4 text-sm text-[var(--stor-muted-text)]">
|
|
844
|
+
Pin an event to keep its countdown here.
|
|
845
|
+
</p>
|
|
846
|
+
{/if}
|
|
847
|
+
</section>
|
|
848
|
+
{/snippet}
|
|
849
|
+
|
|
850
|
+
{#snippet sourcesMenu()}
|
|
851
|
+
{#if showSourceMenu || compactHeader.current}
|
|
852
|
+
<Popover.Root>
|
|
853
|
+
<Popover.Trigger
|
|
854
|
+
class="inline-flex h-11 items-center gap-1.5 rounded-xl bg-[var(--stor-surface-raised)] px-3 text-sm font-semibold max-sm:w-11 max-sm:justify-center max-sm:px-0 text-[var(--stor-ink)] shadow-[var(--stor-shadow-sm)] transition hover:bg-[var(--stor-surface-muted)] hover:shadow-[var(--stor-shadow-md)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] sm:h-9"
|
|
855
|
+
aria-label={`Sources${hiddenSourceCount > 0 ? `, ${hiddenSourceCount} hidden` : ""}`}
|
|
856
|
+
>
|
|
857
|
+
<IconFilter class="h-5 w-5 shrink-0 sm:hidden" />
|
|
858
|
+
<span class="max-sm:hidden">Sources</span>
|
|
859
|
+
{#if hiddenSourceCount > 0}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--stor-accent)] max-sm:absolute max-sm:ml-6 max-sm:-mt-6" aria-hidden="true"></span>{/if}
|
|
860
|
+
<IconChevronDown class="h-4 w-4 shrink-0 max-sm:hidden" />
|
|
861
|
+
</Popover.Trigger>
|
|
862
|
+
<Popover.Portal>
|
|
863
|
+
<Popover.Content
|
|
864
|
+
side="bottom"
|
|
865
|
+
align="end"
|
|
866
|
+
sideOffset={8}
|
|
867
|
+
class="stor-menu z-[var(--stor-layer-menu)] w-64 p-2 outline-none"
|
|
868
|
+
>
|
|
869
|
+
{#if showSourceMenu}
|
|
870
|
+
<p class="px-2.5 pb-1 pt-1.5 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-[var(--stor-muted-text)]">Sources</p>
|
|
871
|
+
<ul>
|
|
872
|
+
{#each sourceMenuEntries as entry (entry.id)}
|
|
873
|
+
{@const visible = isCalendarMenuEntryVisible(entry, effectivePreference)}
|
|
874
|
+
{@const Icon = calendarSourceIcon(entry.icon)}
|
|
875
|
+
<li>
|
|
876
|
+
<label class="flex min-h-11 cursor-pointer items-center gap-2.5 rounded-xl px-2.5 text-sm text-[var(--stor-ink)] transition-colors hover:bg-[var(--stor-surface-muted)] sm:min-h-9">
|
|
877
|
+
<Checkbox
|
|
878
|
+
checked={visible}
|
|
879
|
+
disabled={preferenceSaving}
|
|
880
|
+
onCheckedChange={() => void toggleSourceEntry(entry.id)}
|
|
881
|
+
aria-label={entry.label}
|
|
882
|
+
/>
|
|
883
|
+
<Icon class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
884
|
+
<span class="truncate">{entry.label}</span>
|
|
885
|
+
</label>
|
|
886
|
+
</li>
|
|
887
|
+
{/each}
|
|
888
|
+
</ul>
|
|
889
|
+
{#if hiddenSourceCount > 0}
|
|
890
|
+
<div class="mt-1 border-t border-[var(--stor-border)] pt-1">
|
|
891
|
+
<button
|
|
892
|
+
type="button"
|
|
893
|
+
class="flex min-h-11 w-full items-center rounded-xl px-2.5 text-left text-sm font-semibold text-[var(--stor-accent-text)] transition-colors hover:bg-[var(--stor-surface-muted)] sm:min-h-9"
|
|
894
|
+
onclick={showAllSources}
|
|
895
|
+
>Show all sources</button>
|
|
896
|
+
</div>
|
|
897
|
+
{/if}
|
|
898
|
+
{/if}
|
|
899
|
+
{#if compactHeader.current}
|
|
900
|
+
<CommandOnly>
|
|
901
|
+
<div class="{showSourceMenu ? 'mt-1 border-t border-[var(--stor-border)] pt-1' : ''}">
|
|
902
|
+
<Popover.Close
|
|
903
|
+
class="flex min-h-11 w-full items-center gap-2.5 rounded-xl px-2.5 text-left text-sm text-[var(--stor-ink)] transition-colors hover:bg-[var(--stor-surface-muted)]"
|
|
904
|
+
onclick={() => feedsOpen = true}
|
|
905
|
+
><IconRss class="h-4 w-4 text-[var(--stor-muted-text)]" />Feeds</Popover.Close>
|
|
906
|
+
<Popover.Close
|
|
907
|
+
class="flex min-h-11 w-full items-center gap-2.5 rounded-xl px-2.5 text-left text-sm text-[var(--stor-ink)] transition-colors hover:bg-[var(--stor-surface-muted)] disabled:opacity-50"
|
|
908
|
+
onclick={() => void exportIcs()}
|
|
909
|
+
disabled={displayResource.loading || exporting}
|
|
910
|
+
><IconExport class="h-4 w-4 text-[var(--stor-muted-text)]" />Export .ics</Popover.Close>
|
|
911
|
+
</div>
|
|
912
|
+
</CommandOnly>
|
|
913
|
+
{/if}
|
|
914
|
+
</Popover.Content>
|
|
915
|
+
</Popover.Portal>
|
|
916
|
+
</Popover.Root>
|
|
917
|
+
{/if}
|
|
918
|
+
{/snippet}
|
|
919
|
+
|
|
920
|
+
{#snippet weekRow(week: string, loaded: CalendarWeek | undefined)}
|
|
921
|
+
<div class="stor-calendar-week" role="row" style="--stor-cal-bands: {loaded?.bands.length ?? 0}">
|
|
922
|
+
{#each weekDays(week) as dayKey, col (dayKey)}
|
|
923
|
+
{@const info = dayInfo(dayKey)}
|
|
924
|
+
{@const cell = loaded?.days[col]}
|
|
925
|
+
{@const cellItems = cell?.items ?? []}
|
|
926
|
+
{@const cellRanges = loaded && cell ? cell.lanes.map((lane) => loaded.bands[lane].range) : []}
|
|
927
|
+
{@const hasItems = cellItems.length + cellRanges.length > 0}
|
|
928
|
+
{@const today = dayKey === data.today}
|
|
929
|
+
{@const selected = dayKey === selectedDate}
|
|
930
|
+
{@const edges = monthEdges(dayKey)}
|
|
931
|
+
<div
|
|
932
|
+
role="gridcell"
|
|
933
|
+
data-calendar-day={dayKey}
|
|
934
|
+
aria-label={dayLabel(info, cellItems, cellRanges)}
|
|
935
|
+
aria-selected={selected}
|
|
936
|
+
style="grid-column: {col + 1}; grid-row: 1"
|
|
937
|
+
class="stor-calendar-day group {selected ? 'bg-[var(--stor-selected)]' : 'hover:bg-[var(--stor-surface-hover)]'}"
|
|
938
|
+
onclick={() => { if (phone.current) selectDay(dayKey); }}
|
|
939
|
+
class:stor-calendar-out={monthKeyOf(dayKey) !== shownMonth}
|
|
940
|
+
class:stor-calendar-month-top={edges.top}
|
|
941
|
+
class:stor-calendar-month-start={edges.start}
|
|
942
|
+
>
|
|
943
|
+
<button
|
|
944
|
+
type="button"
|
|
945
|
+
onclick={() => selectDay(dayKey)}
|
|
946
|
+
aria-label={`${formatDay(info.date, "EEEE, MMMM d, yyyy")}${today ? ", today" : ""}${selected ? ", selected" : ""}${hasItems ? `, ${cellItems.length + cellRanges.length} items` : ", no events"}`}
|
|
947
|
+
aria-pressed={selected}
|
|
948
|
+
class="stor-calendar-select flex w-full items-center rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
|
|
949
|
+
>
|
|
950
|
+
<span aria-current={today ? "date" : undefined} class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-medium {selected || today ? 'bg-[var(--stor-action)] text-[var(--stor-action-text)]' : info.isWeekend || info.isHoliday ? 'text-[var(--stor-muted-text)]' : 'text-[var(--stor-ink)]'}">{info.date.getDate()}</span>
|
|
951
|
+
{#if dayKey.endsWith("-01")}<span class="ml-1 hidden text-[10.5px] font-semibold uppercase tracking-wide text-[var(--stor-muted-text)] sm:inline" aria-hidden="true">{formatDay(info.date, "MMM")}</span>{/if}
|
|
952
|
+
</button>
|
|
953
|
+
<div class="mt-1 hidden items-center justify-center gap-1 md:flex lg:hidden" aria-hidden="true">
|
|
954
|
+
{#each cellItems.slice(0, 3) as item (item.sourceKey + item.id)}
|
|
955
|
+
<span class="h-1.5 w-1.5 rounded-full {TONE_DOTS[calendarSourcePresentation(item.sourceKey).tone]}"></span>
|
|
956
|
+
{/each}
|
|
957
|
+
{#if cellRanges.length > 0}
|
|
958
|
+
<span class="h-1.5 w-1.5 rounded-full {TONE_DOTS[calendarSourcePresentation(cellRanges[0].sourceKey).tone]}"></span>
|
|
959
|
+
{/if}
|
|
960
|
+
{#if hasItems}
|
|
961
|
+
<span class="text-[10.5px] font-semibold tabular-nums text-[var(--stor-muted-text)]">{cellItems.length + cellRanges.length}</span>
|
|
962
|
+
{/if}
|
|
963
|
+
</div>
|
|
964
|
+
|
|
965
|
+
<!-- Phones name the day's events like the desktop; a tap anywhere in the day opens it. -->
|
|
966
|
+
<div class="mt-1 hidden space-y-1 max-md:block max-md:pointer-events-none lg:block">
|
|
967
|
+
{#if info.holidayName}
|
|
968
|
+
<span class="block truncate rounded-lg bg-[var(--stor-surface-muted)] px-1.5 py-1 text-[10.5px] font-semibold text-[var(--stor-muted-text)]" title={info.holidayName}>{info.holidayName}</span>
|
|
969
|
+
{/if}
|
|
970
|
+
{#if !loaded}<div class="mt-2 h-3 rounded bg-[var(--stor-surface-muted)] motion-safe:animate-pulse" aria-label="Loading events"></div>{/if}
|
|
971
|
+
{#each cellItems.slice(0, MAX_DAY_CHIPS) as item (item.sourceKey + item.id)}
|
|
972
|
+
{@const presentation = calendarSourcePresentation(item.sourceKey)}
|
|
973
|
+
{@const href = itemHref(item, dayKey)}
|
|
974
|
+
{@const chipClass = `flex w-full min-w-0 items-center gap-1 rounded-lg px-1.5 py-1 text-left text-[10.5px] max-md:rounded-md max-md:px-1 max-md:py-0.5 max-md:text-[10px] max-md:tracking-[-0.01em] transition hover:bg-[var(--stor-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] ${TONE_CLASSES[presentation.tone]}`}
|
|
975
|
+
{#if item.sourceKey === "event"}
|
|
976
|
+
<button
|
|
977
|
+
type="button"
|
|
978
|
+
class={chipClass}
|
|
979
|
+
onclick={() => runCommandAction(() => void openItem(item))}
|
|
980
|
+
aria-label={itemAriaLabel(item)}
|
|
981
|
+
>
|
|
982
|
+
{#if item.startTime}<span class="hidden shrink-0 font-mono text-[var(--stor-muted-text)] xl:inline">{item.startTime}</span>{/if}
|
|
983
|
+
<span class="min-w-0 flex-1 truncate font-semibold">{item.title}</span>
|
|
984
|
+
{#if item.pinned}<IconPin class="h-3 w-3 shrink-0 text-[var(--stor-accent-text)]" />{/if}
|
|
985
|
+
</button>
|
|
986
|
+
{:else if href}
|
|
987
|
+
<a
|
|
988
|
+
href={href}
|
|
989
|
+
class={chipClass}
|
|
990
|
+
aria-label={itemAriaLabel(item)}
|
|
991
|
+
><span class="min-w-0 flex-1 truncate font-semibold">{item.title}</span></a>
|
|
992
|
+
{:else}
|
|
993
|
+
<span class={chipClass} aria-label={itemAriaLabel(item)}>
|
|
994
|
+
<span class="min-w-0 flex-1 truncate font-semibold">{item.title}</span>
|
|
995
|
+
</span>
|
|
996
|
+
{/if}
|
|
997
|
+
{/each}
|
|
998
|
+
{#if cellItems.length > MAX_DAY_CHIPS}
|
|
999
|
+
<button
|
|
1000
|
+
type="button"
|
|
1001
|
+
class="w-full rounded-lg px-1.5 py-0.5 text-left text-[10.5px] font-semibold text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-hover)]"
|
|
1002
|
+
onclick={() => selectDay(dayKey)}
|
|
1003
|
+
>+{cellItems.length - MAX_DAY_CHIPS} more</button>
|
|
1004
|
+
{/if}
|
|
1005
|
+
</div>
|
|
1006
|
+
<CommandOnly>
|
|
1007
|
+
<button
|
|
1008
|
+
type="button"
|
|
1009
|
+
aria-label={`Add event on ${formatDay(info.date, "EEEE, MMMM d, yyyy")}`}
|
|
1010
|
+
title="Add event on this day"
|
|
1011
|
+
onclick={() => startCreate(dayKey)}
|
|
1012
|
+
class="absolute bottom-1 right-0 hidden h-8 w-8 items-center justify-center rounded-md text-[var(--stor-muted-text)] hover:bg-[var(--stor-accent-soft)] hover:text-[var(--stor-accent-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] opacity-0 group-hover:opacity-100 focus:opacity-100 lg:inline-flex"
|
|
1013
|
+
><IconPlus class="h-3.5 w-3.5" /></button>
|
|
1014
|
+
</CommandOnly>
|
|
1015
|
+
</div>
|
|
1016
|
+
{/each}
|
|
1017
|
+
|
|
1018
|
+
{#each loaded?.bands ?? [] as band (band.range.sourceKey + band.range.id)}
|
|
1019
|
+
{@const href = rangeHref(band.range)}
|
|
1020
|
+
{@const presentation = calendarSourcePresentation(band.range.sourceKey)}
|
|
1021
|
+
<a
|
|
1022
|
+
class="stor-calendar-band {TONE_CLASSES[presentation.tone]}"
|
|
1023
|
+
style="grid-column: {band.startCol + 1} / {band.endCol + 2}; grid-row: 1; --stor-cal-lane: {band.lane}"
|
|
1024
|
+
href={href ?? undefined}
|
|
1025
|
+
aria-label={rangeAriaLabel(band.range)}
|
|
1026
|
+
>
|
|
1027
|
+
<span class="truncate">{band.range.title}</span>
|
|
1028
|
+
</a>
|
|
1029
|
+
{/each}
|
|
1030
|
+
</div>
|
|
1031
|
+
{/snippet}
|
|
1032
|
+
|
|
1033
|
+
<div class="stor-calendar-page stor-page w-full p-2.5 max-md:flex max-md:h-full max-md:flex-col sm:p-3 lg:h-full">
|
|
1034
|
+
<PageHeader title="Calendar" class="max-md:sr-only">
|
|
1035
|
+
{#snippet tabs()}
|
|
1036
|
+
<MonthNavigator year={currentYear} month={currentMonth + 1} currentYear={boundaryNow.getFullYear()} currentMonth={boundaryNow.getMonth() + 1} subtitle={`${workingDayCount} working days`} onNavigate={chooseMonth} compact />
|
|
1037
|
+
{/snippet}
|
|
1038
|
+
{@render sourcesMenu()}
|
|
1039
|
+
<CommandOnly>
|
|
1040
|
+
<Button
|
|
1041
|
+
size="sm"
|
|
1042
|
+
variant="secondary"
|
|
1043
|
+
class="hidden lg:inline-flex"
|
|
1044
|
+
onclick={() => feedsOpen = true}
|
|
1045
|
+
><IconRss class="h-4 w-4" />Feeds</Button>
|
|
1046
|
+
|
|
1047
|
+
<Button
|
|
1048
|
+
size="sm"
|
|
1049
|
+
variant="secondary"
|
|
1050
|
+
class="hidden lg:inline-flex"
|
|
1051
|
+
onclick={() => void exportIcs()}
|
|
1052
|
+
disabled={displayResource.loading || exporting}
|
|
1053
|
+
><IconExport class="h-4 w-4" />Export .ics</Button>
|
|
1054
|
+
|
|
1055
|
+
<Button
|
|
1056
|
+
size="sm"
|
|
1057
|
+
class="hidden lg:inline-flex"
|
|
1058
|
+
onclick={() => startCreate()}
|
|
1059
|
+
><IconPlus class="h-4 w-4" />Add event</Button>
|
|
1060
|
+
</CommandOnly>
|
|
1061
|
+
</PageHeader>
|
|
1062
|
+
<p id="calendar-keyboard-help" class="sr-only">
|
|
1063
|
+
Use arrow keys to move by month, Page Up and Page Down to move by year,
|
|
1064
|
+
and Home to return to today.
|
|
1065
|
+
</p>
|
|
1066
|
+
|
|
1067
|
+
<div class="stor-calendar-layout">
|
|
1068
|
+
<section
|
|
1069
|
+
bind:this={calendarControl}
|
|
1070
|
+
tabindex="0"
|
|
1071
|
+
onkeydown={handleCalendarKeydown}
|
|
1072
|
+
aria-label={`${monthLabel} calendar`}
|
|
1073
|
+
aria-describedby="calendar-keyboard-help"
|
|
1074
|
+
class="stor-panel stor-calendar-panel min-w-0 p-2 outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] lg:p-4"
|
|
1075
|
+
data-under-action={phone.current ? "" : undefined}
|
|
1076
|
+
>
|
|
1077
|
+
<!-- Phones: the month in view and the sources head the grid; the top bar names the page. -->
|
|
1078
|
+
<div class="-mx-2 -mt-2 mb-1 flex h-12 shrink-0 items-center gap-1.5 border-b border-[color-mix(in_srgb,var(--stor-border)_62%,transparent)] pl-2 pr-1 md:hidden">
|
|
1079
|
+
<MonthNavigator year={currentYear} month={currentMonth + 1} currentYear={boundaryNow.getFullYear()} currentMonth={boundaryNow.getMonth() + 1} onNavigate={chooseMonth} compact phoneArrows={false} class="min-w-0" />
|
|
1080
|
+
<div class="ml-auto">{@render sourcesMenu()}</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
<div class="stor-calendar-grid" role="grid" aria-label={`${monthLabel} days`}>
|
|
1083
|
+
<div class="stor-calendar-week stor-calendar-header" role="row">
|
|
1084
|
+
{#each ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as dayName}
|
|
1085
|
+
<div role="columnheader" class="px-0.5 py-2 text-center text-xs font-medium text-[var(--stor-muted-text)] sm:px-2.5 sm:text-xs">{dayName}</div>
|
|
1086
|
+
{/each}
|
|
1087
|
+
</div>
|
|
1088
|
+
<div class="stor-calendar-weeks">
|
|
1089
|
+
<!-- The weeks open where the applied data says and read with its source filter, so they wait for it. -->
|
|
1090
|
+
{#if hasPaintedMonth}
|
|
1091
|
+
<EndlessList
|
|
1092
|
+
bind:this={list}
|
|
1093
|
+
axis={axis}
|
|
1094
|
+
store={weeks}
|
|
1095
|
+
opening={monthTarget(shownMonth)}
|
|
1096
|
+
estimate={weekEstimate}
|
|
1097
|
+
revision={revision}
|
|
1098
|
+
home={todayTarget}
|
|
1099
|
+
pick={pick}
|
|
1100
|
+
item={weekRow}
|
|
1101
|
+
onVisibleChange={(position) => followVisible(position.at)}
|
|
1102
|
+
class="stor-calendar-scroller"
|
|
1103
|
+
data-calendar-scroll
|
|
1104
|
+
/>
|
|
1105
|
+
{:else}
|
|
1106
|
+
<DataPlaceholder rows={6} label="Loading calendar" />
|
|
1107
|
+
{/if}
|
|
1108
|
+
</div>
|
|
1109
|
+
</div>
|
|
1110
|
+
</section>
|
|
1111
|
+
|
|
1112
|
+
<aside class="stor-calendar-agendas max-md:hidden">
|
|
1113
|
+
<section class="stor-panel p-[var(--stor-panel-padding)]" aria-labelledby="agenda-heading">
|
|
1114
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
1115
|
+
<div>
|
|
1116
|
+
<h2 id="agenda-heading" class="font-semibold text-[var(--stor-ink)]">{selectedDateLabel}</h2>
|
|
1117
|
+
<p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">{selectedDaySummary}</p>
|
|
1118
|
+
</div>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div class="mt-3 space-y-2">
|
|
1121
|
+
{@render dayAgenda()}
|
|
1122
|
+
</div>
|
|
1123
|
+
</section>
|
|
1124
|
+
|
|
1125
|
+
<!-- Phones read the day, then what is pinned; the month's working-day figures come last. -->
|
|
1126
|
+
<CalendarMonthSummary
|
|
1127
|
+
class="order-last lg:order-first"
|
|
1128
|
+
year={currentYear}
|
|
1129
|
+
monthIndex={currentMonth}
|
|
1130
|
+
today={data.today}
|
|
1131
|
+
counts={monthCounts}
|
|
1132
|
+
sourceLabel={(sourceKey) => sourceByKey.get(sourceKey)?.label ?? sourceKey}
|
|
1133
|
+
loading={countsLoading}
|
|
1134
|
+
/>
|
|
1135
|
+
|
|
1136
|
+
{@render pinnedPanel()}
|
|
1137
|
+
</aside>
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
|
|
1141
|
+
<MobilePageAction hideWhenModalOpen isModalOpen={drawerOpen} hidden={peekOpen && phone.current}>
|
|
1142
|
+
<Button class="text-[15px]" onclick={() => startCreate(selectedDate)}>
|
|
1143
|
+
<IconPlus class="h-5 w-5" /> <span class="stor-action-label">Add event</span>
|
|
1144
|
+
</Button>
|
|
1145
|
+
</MobilePageAction>
|
|
1146
|
+
|
|
1147
|
+
{#if peekOpen && phone.current && selectedDate}
|
|
1148
|
+
<!-- The day's agenda above the dock; the grid stays live above it, so another day switches it. -->
|
|
1149
|
+
<section
|
|
1150
|
+
class="stor-sheet fixed inset-x-2.5 bottom-[calc(var(--stor-mobile-bottom-inset)+var(--stor-mobile-nav-height)+8px)] z-40 flex max-h-[42dvh] flex-col overflow-hidden rounded-[22px] md:hidden"
|
|
1151
|
+
aria-label={selectedDateLabel}
|
|
1152
|
+
aria-live="polite"
|
|
1153
|
+
data-calendar-peek
|
|
1154
|
+
>
|
|
1155
|
+
<header class="flex shrink-0 items-center gap-2 px-4 pb-2 pt-3">
|
|
1156
|
+
<div class="min-w-0 flex-1">
|
|
1157
|
+
<h2 class="stor-serif-title truncate text-[19px] text-[var(--stor-ink)]">{formatDay(new Date(`${selectedDate}T12:00:00`), "EEEE d MMMM")}</h2>
|
|
1158
|
+
<p class="truncate text-xs text-[var(--stor-muted-text)]">{selectedDaySummary}</p>
|
|
1159
|
+
</div>
|
|
1160
|
+
<CommandOnly>
|
|
1161
|
+
<Button size="sm" class="h-9 rounded-full" onclick={() => startCreate(selectedDate)}><IconPlus class="h-4 w-4" /> Add</Button>
|
|
1162
|
+
</CommandOnly>
|
|
1163
|
+
<button type="button" class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)]" aria-label="Close the day" onclick={() => (peekOpen = false)}><IconClose class="h-5 w-5" /></button>
|
|
1164
|
+
</header>
|
|
1165
|
+
<div class="min-h-0 space-y-2 overflow-y-auto px-3 pb-3">{@render dayAgenda()}</div>
|
|
1166
|
+
</section>
|
|
1167
|
+
{/if}
|
|
1168
|
+
|
|
1169
|
+
<PhoneGlance
|
|
1170
|
+
label={nextPinned ? `Pinned · ${describeEventCountdown(daysUntilCalendarEvent(nextPinned.event_date, boundaryNow)).toLowerCase()}` : monthLabel}
|
|
1171
|
+
value={nextPinned ? nextPinned.title : `${workingDayCount} working days`}
|
|
1172
|
+
title={monthLabel}
|
|
1173
|
+
>
|
|
1174
|
+
<div class="flex flex-col gap-[var(--stor-section-gap)]">
|
|
1175
|
+
<CalendarMonthSummary
|
|
1176
|
+
year={currentYear}
|
|
1177
|
+
monthIndex={currentMonth}
|
|
1178
|
+
today={data.today}
|
|
1179
|
+
counts={monthCounts}
|
|
1180
|
+
sourceLabel={(sourceKey) => sourceByKey.get(sourceKey)?.label ?? sourceKey}
|
|
1181
|
+
loading={countsLoading}
|
|
1182
|
+
/>
|
|
1183
|
+
{@render pinnedPanel()}
|
|
1184
|
+
</div>
|
|
1185
|
+
</PhoneGlance>
|
|
1186
|
+
|
|
1187
|
+
<CommandDrawer show={drawerOpen} title={editingId ? "Edit event" : "Add event"} description="Add a date, an optional time, and anything you want to remember." onClose={() => drawerOpen = false}>
|
|
1188
|
+
<form id={drawerFormId0} class="space-y-4" onsubmit={saveEvent}>
|
|
1189
|
+
<Field label="Event title" htmlFor="calendar-event-title" required error={errors.title}>
|
|
1190
|
+
<Input id="calendar-event-title" bind:value={draft.title} placeholder="e.g. Project launch" autofocus />
|
|
1191
|
+
</Field>
|
|
1192
|
+
<div class="grid gap-3 sm:grid-cols-2">
|
|
1193
|
+
<Field label="Date" htmlFor="calendar-event-date" required error={errors.eventDate}>
|
|
1194
|
+
<DatePicker id="calendar-event-date" bind:value={draft.eventDate} placeholder="Choose event date" />
|
|
1195
|
+
</Field>
|
|
1196
|
+
<Field label="Time" htmlFor="calendar-event-time" hint={phone.current ? "Blank is all day" : "Optional · leave blank for all day"} error={errors.startTime}>
|
|
1197
|
+
<Input id="calendar-event-time" type="time" bind:value={draft.startTime} />
|
|
1198
|
+
</Field>
|
|
1199
|
+
</div>
|
|
1200
|
+
{#if phone.current}
|
|
1201
|
+
<!-- Phones: the pin as a switch; notes fold until asked for (or already written). -->
|
|
1202
|
+
<Switch checked={draft.pinned} label="Pin this event" hint="Count down to it on Calendar" onCheckedChange={(checked) => (draft.pinned = checked)} />
|
|
1203
|
+
{#if eventNotesOpen || draft.notes}
|
|
1204
|
+
<Textarea id="calendar-event-notes" bind:value={draft.notes} placeholder="Notes" />
|
|
1205
|
+
{:else}
|
|
1206
|
+
<button type="button" class="flex items-center gap-1 text-sm font-semibold text-[var(--stor-accent-text)]" onclick={() => (eventNotesOpen = true)}>Notes<IconChevronDown class="h-4 w-4" /></button>
|
|
1207
|
+
{/if}
|
|
1208
|
+
{:else}
|
|
1209
|
+
<Field label="Notes" htmlFor="calendar-event-notes" hint="Optional">
|
|
1210
|
+
<Textarea id="calendar-event-notes" bind:value={draft.notes} placeholder="Add details or context" />
|
|
1211
|
+
</Field>
|
|
1212
|
+
<label class="flex items-start gap-3 rounded-xl bg-[var(--stor-surface-muted)] p-3">
|
|
1213
|
+
<Checkbox checked={draft.pinned} onCheckedChange={(checked) => draft.pinned = Boolean(checked)} aria-label="Pin event" />
|
|
1214
|
+
<span>
|
|
1215
|
+
<span class="block text-sm font-semibold text-[var(--stor-ink)]">Pin this event</span>
|
|
1216
|
+
<span class="mt-0.5 block text-xs text-[var(--stor-muted-text)]">Show its countdown on Calendar and Today.</span>
|
|
1217
|
+
</span>
|
|
1218
|
+
</label>
|
|
1219
|
+
{/if}
|
|
1220
|
+
</form>
|
|
1221
|
+
{#snippet footer()}
|
|
1222
|
+
<div class="mr-auto">
|
|
1223
|
+
{#if editingId}
|
|
1224
|
+
<Button
|
|
1225
|
+
variant="ghost"
|
|
1226
|
+
class="text-[var(--stor-negative)] hover:bg-[var(--stor-negative-soft)] hover:text-[var(--stor-negative)]"
|
|
1227
|
+
onclick={() => requestDelete(editingId!)}
|
|
1228
|
+
><IconDelete class="h-4 w-4" />Delete</Button>
|
|
1229
|
+
{/if}
|
|
1230
|
+
</div>
|
|
1231
|
+
|
|
1232
|
+
<Button
|
|
1233
|
+
variant="secondary"
|
|
1234
|
+
onclick={() => drawerOpen = false}
|
|
1235
|
+
>Cancel</Button>
|
|
1236
|
+
|
|
1237
|
+
<Button
|
|
1238
|
+
type="submit"
|
|
1239
|
+
form={drawerFormId0}
|
|
1240
|
+
disabled={saving}
|
|
1241
|
+
>{saving ? "Saving…" : editingId ? "Save changes" : "Add event"}</Button>
|
|
1242
|
+
{/snippet}
|
|
1243
|
+
</CommandDrawer>
|
|
1244
|
+
|
|
1245
|
+
<CommandConfirmDialog
|
|
1246
|
+
show={deleteId !== null}
|
|
1247
|
+
title="Delete event?"
|
|
1248
|
+
message={editingEvent ? `“${editingEvent.title}” will be removed from the calendar.` : "This event will be removed from the calendar."}
|
|
1249
|
+
onConfirm={removeEvent}
|
|
1250
|
+
onCancel={() => deleteId = null}
|
|
1251
|
+
/>
|
|
1252
|
+
|
|
1253
|
+
<CalendarFeedsDrawer bind:show={feedsOpen} sources={sources} />
|
|
1254
|
+
|
|
1255
|
+
<style>
|
|
1256
|
+
.stor-calendar-header {
|
|
1257
|
+
display: grid;
|
|
1258
|
+
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
1259
|
+
gap: 2px;
|
|
1260
|
+
}
|
|
1261
|
+
.stor-calendar-week {
|
|
1262
|
+
position: relative;
|
|
1263
|
+
display: grid;
|
|
1264
|
+
grid-column: 1 / -1;
|
|
1265
|
+
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
1266
|
+
gap: var(--stor-cal-gap);
|
|
1267
|
+
padding-bottom: var(--stor-cal-gap);
|
|
1268
|
+
--stor-cal-gap: 2px;
|
|
1269
|
+
--stor-cal-head: 0px;
|
|
1270
|
+
--stor-cal-band-h: 0px;
|
|
1271
|
+
}
|
|
1272
|
+
/* Months meet along one stepped line drawn in the gaps between days. */
|
|
1273
|
+
.stor-calendar-month-top::before,
|
|
1274
|
+
.stor-calendar-month-start::after {
|
|
1275
|
+
content: "";
|
|
1276
|
+
position: absolute;
|
|
1277
|
+
z-index: 1;
|
|
1278
|
+
background: color-mix(in srgb, var(--stor-ink) 30%, transparent);
|
|
1279
|
+
pointer-events: none;
|
|
1280
|
+
}
|
|
1281
|
+
.stor-calendar-month-top::before {
|
|
1282
|
+
top: calc(var(--stor-cal-gap) / -2 - 0.5px);
|
|
1283
|
+
right: calc(var(--stor-cal-gap) / -2 - 0.5px);
|
|
1284
|
+
left: calc(var(--stor-cal-gap) / -2 - 0.5px);
|
|
1285
|
+
height: 1px;
|
|
1286
|
+
}
|
|
1287
|
+
/* Days are a week's first seven children; the line stops at the grid's edges. */
|
|
1288
|
+
.stor-calendar-month-top:nth-child(1)::before {
|
|
1289
|
+
left: 0;
|
|
1290
|
+
}
|
|
1291
|
+
.stor-calendar-month-top:nth-child(7)::before {
|
|
1292
|
+
right: 0;
|
|
1293
|
+
}
|
|
1294
|
+
.stor-calendar-month-start::after {
|
|
1295
|
+
top: calc(var(--stor-cal-gap) / -2 - 0.5px);
|
|
1296
|
+
bottom: calc(var(--stor-cal-gap) / -2 - 0.5px);
|
|
1297
|
+
left: calc(var(--stor-cal-gap) / -2 - 0.5px);
|
|
1298
|
+
width: 1px;
|
|
1299
|
+
}
|
|
1300
|
+
.stor-calendar-band {
|
|
1301
|
+
display: none;
|
|
1302
|
+
}
|
|
1303
|
+
@media (min-width: 1024px) {
|
|
1304
|
+
.stor-calendar-header {
|
|
1305
|
+
gap: 8px;
|
|
1306
|
+
}
|
|
1307
|
+
.stor-calendar-week {
|
|
1308
|
+
--stor-cal-gap: 8px;
|
|
1309
|
+
--stor-cal-head: 40px;
|
|
1310
|
+
--stor-cal-band-h: 20px;
|
|
1311
|
+
}
|
|
1312
|
+
.stor-calendar-week .stor-calendar-day {
|
|
1313
|
+
padding-top: calc(
|
|
1314
|
+
var(--stor-cal-head, 0px) +
|
|
1315
|
+
var(--stor-cal-bands, 0) * var(--stor-cal-band-h, 0px)
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
.stor-calendar-band {
|
|
1319
|
+
display: flex;
|
|
1320
|
+
align-items: center;
|
|
1321
|
+
align-self: start;
|
|
1322
|
+
z-index: 2;
|
|
1323
|
+
height: calc(var(--stor-cal-band-h) - 2px);
|
|
1324
|
+
margin-top: calc(
|
|
1325
|
+
var(--stor-cal-head) + var(--stor-cal-lane, 0) * var(--stor-cal-band-h)
|
|
1326
|
+
);
|
|
1327
|
+
padding: 0 6px;
|
|
1328
|
+
border-radius: 6px;
|
|
1329
|
+
font-size: 0.65rem;
|
|
1330
|
+
font-weight: 600;
|
|
1331
|
+
overflow: hidden;
|
|
1332
|
+
transition: filter 160ms ease;
|
|
1333
|
+
}
|
|
1334
|
+
.stor-calendar-band:hover {
|
|
1335
|
+
filter: brightness(0.97);
|
|
1336
|
+
}
|
|
1337
|
+
.stor-calendar-header {
|
|
1338
|
+
padding-top: 0;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
</style>
|