Skip to content

Commit 2a0b8d3

Browse files
committed
add calendar settings + custom visual snapping
1 parent d2f3fe4 commit 2a0b8d3

File tree

6 files changed

+771
-83
lines changed

6 files changed

+771
-83
lines changed

e2e/calendar-settings.spec.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
2+
import { test } from '../playwright/fixtures';
3+
import { expect } from '@playwright/test';
4+
import type { Page } from '@playwright/test';
5+
6+
async function goToCalendar(page: Page) {
7+
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
8+
await expect(page.locator('.fc')).toBeVisible();
9+
}
10+
11+
async function openSettingsPopover(page: Page) {
12+
await page.getByRole('button', { name: 'Calendar settings' }).click();
13+
await expect(page.getByText('Calendar Settings')).toBeVisible();
14+
}
15+
16+
async function clearCalendarSettings(page: Page) {
17+
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
18+
}
19+
20+
test.describe('Calendar Settings', () => {
21+
test.beforeEach(async ({ page }) => {
22+
await clearCalendarSettings(page);
23+
});
24+
25+
test('settings popover shows all fields with correct defaults', async ({ page }) => {
26+
await goToCalendar(page);
27+
await openSettingsPopover(page);
28+
29+
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
30+
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
31+
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
32+
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
33+
});
34+
35+
test('snap interval can be changed and persists across reload', async ({ page }) => {
36+
await goToCalendar(page);
37+
await openSettingsPopover(page);
38+
39+
// Change snap interval to 30 min
40+
await page.getByLabel('Snap Interval').click();
41+
await page.getByRole('option', { name: '30 min' }).click();
42+
await page.locator('.fc-toolbar-title').click();
43+
44+
// Verify localStorage was updated
45+
const stored = await page.evaluate(() =>
46+
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
47+
);
48+
expect(stored.snapMinutes).toBe(30);
49+
50+
// Reload and verify persistence
51+
await page.reload();
52+
await expect(page.locator('.fc')).toBeVisible();
53+
await openSettingsPopover(page);
54+
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
55+
});
56+
57+
test('start time change is applied to calendar and rejects values >= end time', async ({
58+
page,
59+
}) => {
60+
await goToCalendar(page);
61+
62+
// Verify 7 AM slot exists with default start (00:00)
63+
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
64+
65+
await openSettingsPopover(page);
66+
67+
// Set end time to 6 PM first
68+
await page.getByLabel('End Time').click();
69+
await page.getByRole('option', { name: '6:00 PM' }).click();
70+
71+
// Change start time to 8 AM (valid)
72+
await page.getByLabel('Start Time').click();
73+
await page.getByRole('option', { name: '8:00 AM' }).click();
74+
await page.locator('.fc-toolbar-title').click();
75+
76+
// Calendar should no longer show hours before 8 AM
77+
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
78+
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
79+
80+
// Try to set start time to 6 PM (invalid: equals end time)
81+
await openSettingsPopover(page);
82+
await page.getByLabel('Start Time').click();
83+
await page.getByRole('option', { name: '6:00 PM' }).click();
84+
85+
// Should be rejected — start time stays at 8 AM
86+
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
87+
});
88+
89+
test('end time change is applied to calendar and rejects values <= start time', async ({
90+
page,
91+
}) => {
92+
await goToCalendar(page);
93+
94+
// Verify 19:00 slot exists with default end (24:00)
95+
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
96+
97+
await openSettingsPopover(page);
98+
99+
// Set start time to 8 AM first
100+
await page.getByLabel('Start Time').click();
101+
await page.getByRole('option', { name: '8:00 AM' }).click();
102+
103+
// Change end time to 6 PM (valid)
104+
await page.getByLabel('End Time').click();
105+
await page.getByRole('option', { name: '6:00 PM' }).click();
106+
await page.locator('.fc-toolbar-title').click();
107+
108+
// Calendar should no longer show hours at or after 6 PM
109+
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
110+
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
111+
112+
// Try to set end time to 8 AM (invalid: equals start time)
113+
await openSettingsPopover(page);
114+
await page.getByLabel('End Time').click();
115+
await page.getByRole('option', { name: '8:00 AM' }).click();
116+
117+
// Should be rejected — end time stays at 6 PM
118+
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
119+
});
120+
121+
test('grid scale affects number of calendar slots', async ({ page }) => {
122+
await goToCalendar(page);
123+
124+
// Count slots with default 15-min scale
125+
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
126+
127+
// Change to 30 min scale (should halve the slots)
128+
await openSettingsPopover(page);
129+
await page.getByLabel('Grid Scale').click();
130+
await page.getByRole('option', { name: '30 min' }).click();
131+
await page.locator('.fc-toolbar-title').click();
132+
133+
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
134+
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
135+
136+
// Change to 5 min scale (should have many more slots)
137+
await openSettingsPopover(page);
138+
await page.getByLabel('Grid Scale').click();
139+
await page.getByRole('option', { name: '5 min', exact: true }).click();
140+
await page.locator('.fc-toolbar-title').click();
141+
142+
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
143+
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
144+
});
145+
146+
test('all settings persist across navigation', async ({ page }) => {
147+
await goToCalendar(page);
148+
await openSettingsPopover(page);
149+
150+
// Change every setting
151+
await page.getByLabel('Snap Interval').click();
152+
await page.getByRole('option', { name: '5 min', exact: true }).click();
153+
await page.getByLabel('Start Time').click();
154+
await page.getByRole('option', { name: '6:00 AM' }).click();
155+
await page.getByLabel('End Time').click();
156+
await page.getByRole('option', { name: '10:00 PM' }).click();
157+
await page.getByLabel('Grid Scale').click();
158+
await page.getByRole('option', { name: '30 min' }).click();
159+
await page.locator('.fc-toolbar-title').click();
160+
161+
// Navigate away and back
162+
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
163+
await goToCalendar(page);
164+
165+
// Verify all settings persisted
166+
await openSettingsPopover(page);
167+
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
168+
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
169+
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
170+
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
171+
});
172+
});
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<script setup lang="ts">
2+
import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
3+
import {
4+
Select,
5+
SelectContent,
6+
SelectItem,
7+
SelectTrigger,
8+
SelectValue,
9+
} from '@/Components/ui/select';
10+
import { Field, FieldLabel } from '../field';
11+
import { Settings } from 'lucide-vue-next';
12+
import { ref, watch } from 'vue';
13+
import type { CalendarSettings } from './calendarSettings';
14+
15+
export type { CalendarSettings };
16+
17+
const props = defineProps<{
18+
settings: CalendarSettings;
19+
}>();
20+
21+
const emit = defineEmits<{
22+
'update:settings': [value: CalendarSettings];
23+
}>();
24+
25+
const snapMinutes = ref(String(props.settings.snapMinutes));
26+
const startHour = ref(String(props.settings.startHour));
27+
const endHour = ref(String(props.settings.endHour));
28+
const slotMinutes = ref(String(props.settings.slotMinutes));
29+
30+
watch(
31+
() => props.settings,
32+
(s) => {
33+
snapMinutes.value = String(s.snapMinutes);
34+
startHour.value = String(s.startHour);
35+
endHour.value = String(s.endHour);
36+
slotMinutes.value = String(s.slotMinutes);
37+
}
38+
);
39+
40+
function emitUpdate(partial: Partial<CalendarSettings>) {
41+
emit('update:settings', { ...props.settings, ...partial });
42+
}
43+
44+
function onSnapChange(value: string) {
45+
snapMinutes.value = value;
46+
emitUpdate({ snapMinutes: parseInt(value) });
47+
}
48+
49+
function onStartHourChange(value: string) {
50+
const newStart = parseInt(value);
51+
// Ensure start < end
52+
if (newStart >= parseInt(endHour.value)) {
53+
startHour.value = String(props.settings.startHour);
54+
return;
55+
}
56+
startHour.value = value;
57+
emitUpdate({ startHour: newStart });
58+
}
59+
60+
function onEndHourChange(value: string) {
61+
const newEnd = parseInt(value);
62+
// Ensure end > start
63+
if (newEnd <= parseInt(startHour.value)) {
64+
endHour.value = String(props.settings.endHour);
65+
return;
66+
}
67+
endHour.value = value;
68+
emitUpdate({ endHour: newEnd });
69+
}
70+
71+
function onSlotChange(value: string) {
72+
slotMinutes.value = value;
73+
emitUpdate({ slotMinutes: parseInt(value) });
74+
}
75+
76+
const snapOptions = [
77+
{ value: '1', label: '1 min' },
78+
{ value: '5', label: '5 min' },
79+
{ value: '10', label: '10 min' },
80+
{ value: '15', label: '15 min' },
81+
{ value: '30', label: '30 min' },
82+
{ value: '60', label: '1 hour' },
83+
];
84+
85+
const slotOptions = [
86+
{ value: '5', label: '5 min' },
87+
{ value: '10', label: '10 min' },
88+
{ value: '15', label: '15 min' },
89+
{ value: '30', label: '30 min' },
90+
{ value: '60', label: '1 hour' },
91+
];
92+
93+
// Generate hour options 0-24
94+
const hourOptions = Array.from({ length: 25 }, (_, i) => ({
95+
value: String(i),
96+
label:
97+
i === 0
98+
? '12:00 AM'
99+
: i === 12
100+
? '12:00 PM'
101+
: i === 24
102+
? '12:00 AM (next)'
103+
: i < 12
104+
? `${i}:00 AM`
105+
: `${i - 12}:00 PM`,
106+
}));
107+
</script>
108+
109+
<template>
110+
<Popover>
111+
<PopoverTrigger as-child>
112+
<Button variant="outline" size="sm" aria-label="Calendar settings" class="h-8 w-8 p-0">
113+
<Settings class="h-4 w-4 text-muted-foreground" />
114+
</Button>
115+
</PopoverTrigger>
116+
<PopoverContent align="end" class="w-72 p-4">
117+
<div class="space-y-4">
118+
<div class="text-sm font-semibold">Calendar Settings</div>
119+
120+
<Field>
121+
<FieldLabel for="calendar-snap">Snap Interval</FieldLabel>
122+
<Select
123+
:model-value="snapMinutes"
124+
@update:model-value="(v) => onSnapChange(v as string)">
125+
<SelectTrigger id="calendar-snap" size="sm" class="w-full">
126+
<SelectValue placeholder="Snap interval" />
127+
</SelectTrigger>
128+
<SelectContent>
129+
<SelectItem
130+
v-for="opt in snapOptions"
131+
:key="opt.value"
132+
:value="opt.value">
133+
{{ opt.label }}
134+
</SelectItem>
135+
</SelectContent>
136+
</Select>
137+
</Field>
138+
139+
<Field>
140+
<FieldLabel for="calendar-start-hour">Start Time</FieldLabel>
141+
<Select
142+
:model-value="startHour"
143+
@update:model-value="(v) => onStartHourChange(v as string)">
144+
<SelectTrigger id="calendar-start-hour" size="sm" class="w-full">
145+
<SelectValue placeholder="Start time" />
146+
</SelectTrigger>
147+
<SelectContent>
148+
<SelectItem
149+
v-for="opt in hourOptions.slice(0, -1)"
150+
:key="opt.value"
151+
:value="opt.value">
152+
{{ opt.label }}
153+
</SelectItem>
154+
</SelectContent>
155+
</Select>
156+
</Field>
157+
158+
<Field>
159+
<FieldLabel for="calendar-end-hour">End Time</FieldLabel>
160+
<Select
161+
:model-value="endHour"
162+
@update:model-value="(v) => onEndHourChange(v as string)">
163+
<SelectTrigger id="calendar-end-hour" size="sm" class="w-full">
164+
<SelectValue placeholder="End time" />
165+
</SelectTrigger>
166+
<SelectContent>
167+
<SelectItem
168+
v-for="opt in hourOptions.slice(1)"
169+
:key="opt.value"
170+
:value="opt.value">
171+
{{ opt.label }}
172+
</SelectItem>
173+
</SelectContent>
174+
</Select>
175+
</Field>
176+
177+
<Field>
178+
<FieldLabel for="calendar-scale">Grid Scale</FieldLabel>
179+
<Select
180+
:model-value="slotMinutes"
181+
@update:model-value="(v) => onSlotChange(v as string)">
182+
<SelectTrigger id="calendar-scale" size="sm" class="w-full">
183+
<SelectValue placeholder="Grid scale" />
184+
</SelectTrigger>
185+
<SelectContent>
186+
<SelectItem
187+
v-for="opt in slotOptions"
188+
:key="opt.value"
189+
:value="opt.value">
190+
{{ opt.label }}
191+
</SelectItem>
192+
</SelectContent>
193+
</Select>
194+
</Field>
195+
</div>
196+
</PopoverContent>
197+
</Popover>
198+
</template>

0 commit comments

Comments
 (0)