Squash commmits

This commit is contained in:
tobias
2025-07-21 11:33:44 +02:00
commit 60d6c1cb8c
32 changed files with 4495 additions and 0 deletions

4
src/app.css Normal file
View File

@ -0,0 +1,4 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes: cmyk --default, dracula --prefersdark;
}

13
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

7
src/demo.spec.ts Normal file
View File

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@ -0,0 +1,79 @@
<!-- https://codepen.io/hexagoncircle/pen/bGZdWyw -->
<script>
let svgEl;
let sparkEl;
export function triggerSpark(e) {
setPosition(e);
animate();
}
function setPosition(e) {
sparkEl.style.left = `${e.pageX - sparkEl.clientWidth / 2}px`;
sparkEl.style.top = `${e.pageY - sparkEl.clientHeight / 2}px`;
}
function animate() {
const sparks = [...svgEl.children];
const size = parseInt(sparks[0].getAttribute('y1'));
const offset = size / 2 + 'px';
const options = {
duration: 660,
easing: 'cubic-bezier(0.25, 1, 0.5, 1)',
fill: 'forwards'
};
sparks.forEach((spark, i) => {
const deg = `calc(${i} * (360deg / ${sparks.length}))`;
const keyframes = [
{
strokeDashoffset: size * 3,
transform: `rotate(${deg}) translateY(${offset})`
},
{
strokeDashoffset: size,
transform: `rotate(${deg}) translateY(0)`
}
];
spark.animate(keyframes, options);
});
}
</script>
<div bind:this={sparkEl} class="spark">
<svg
bind:this={svgEl}
viewBox="0 0 100 100"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
stroke="var(--color-accent, currentcolor)"
>
{#each Array(8) as _}
<line
x1="50"
y1="30"
x2="50"
y2="4"
stroke-dasharray="30"
stroke-dashoffset="30"
style="transform-origin: center"
/>
{/each}
</svg>
</div>
<style>
.spark {
position: absolute;
pointer-events: none;
}
svg {
width: 50px;
height: 50px;
transform: rotate(-20deg);
}
</style>

17
src/lib/crabfitData.ts Normal file
View File

@ -0,0 +1,17 @@
import type { Member } from '$lib/meeting';
import { fetchCrabfitData } from '$lib/meeting';
import { env } from '$env/dynamic/private';
export let crabfitData: Member[] | null = null;
let lastFetchTime = 0;
const TTL = 1000 * 60 * 60 * 12; // 12 hours in ms
export async function getCrabfitData(): Promise<Member[]> {
const now = Date.now();
if (!crabfitData || now - lastFetchTime > TTL) {
crabfitData = await fetchCrabfitData(env.CRABFIT_API_URL);
lastFetchTime = now;
console.log(`${now} cached new crabifit data`);
}
return crabfitData;
}

1
src/lib/index.ts Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

108
src/lib/meeting.ts Normal file
View File

@ -0,0 +1,108 @@
import fetch from 'node-fetch';
import { env } from '$env/dynamic/private';
export type Member = {
name: string;
availability: string[];
};
export type Options = {
members: Member[];
days: string[];
number: number;
};
const dayMap: Record<string, string> = {
Monday: '1',
Tuesday: '2',
Wednesday: '3',
Thursday: '4',
Friday: '5',
Saturday: '6',
Sunday: '7'
};
const dayReverseMap: Record<string, string> = {
'1': 'Monday',
'2': 'Tuesday',
'3': 'Wednesday',
'4': 'Thursday',
'5': 'Friday',
'6': 'Saturday',
'7': 'Sunday'
};
export function findMeetingOptions(
members: Member[],
{ days, number }: Omit<Options, 'members'>
): { [key: string]: [string, string] }[] {
const data: Record<string, string[]> = {};
const selectedDays = days.map((d) => dayMap[d]);
for (const member of members) {
for (const slot of member.availability) {
const [time, day] = slot.split('-');
if (!time.endsWith('00')) continue;
if (!selectedDays.includes(day)) continue;
if (!data[slot]) data[slot] = [];
data[slot].push(member.name);
}
}
const membersCount = members.length;
const slots = Object.keys(data);
const combinations = getCombinations(slots, number);
const seen: Set<string> = new Set();
const options: { [key: string]: [string, string] }[] = [];
let counter = 1;
for (const combo of combinations) {
const unique = new Set(combo);
if (unique.size < number) continue;
const key = [...unique].sort().join(',');
if (seen.has(key)) continue;
seen.add(key);
const attendeesPerSlot = combo.map((slot) => data[slot] ?? []);
const attendees = new Set(attendeesPerSlot.flat());
if (
attendees.size === membersCount &&
attendeesPerSlot.every((s) => s.length >= Math.floor(membersCount / number)) &&
attendeesPerSlot.every((s) => s.length < Math.floor(membersCount / number) + 2)
) {
for (const slot of combo) {
const [time, day] = slot.split('-');
options.push({
[`Option ${counter}`]: [dayReverseMap[day], time]
});
counter += 1;
}
}
}
return options;
}
function getCombinations(arr: string[], size: number): string[][] {
if (size === 0) return [[]];
if (size > arr.length) return [];
if (size === 1) return arr.map((v) => [v]);
const combos: string[][] = [];
for (let i = 0; i < arr.length; i++) {
const rest = getCombinations(arr.slice(i + 1), size - 1);
for (const r of rest) {
combos.push([arr[i], ...r]);
}
}
return combos;
}
export async function fetchCrabfitData(apiUrl): Promise<Member[]> {
const res = await fetch(`${apiUrl}/people`);
return res.json();
}
export async function filterMemberData(members: string[], memberData: Member[]): Promise<Member[]> {
return members.map((name: string) => memberData.find((m) => m.name === name)).filter(Boolean);
}

View File

@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View File

@ -0,0 +1,38 @@
import { fail } from '@sveltejs/kit';
// import type { Actions } from './$types';
import { getCrabfitData } from '$lib/crabfitData';
import { findMeetingOptions, filterMemberData } from '$lib/meeting';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const res = await fetch('/api/members');
const data = await res.json();
return { members: data.names };
};
export const actions: Actions = {
getMeetings: async ({ request }) => {
const form = await request.formData();
const selectedMembers = form.getAll('selectedMembers');
const days = form.getAll('selectedDays');
// const number = form.get('number');
const number = 1;
if (!selectedMembers) return fail(500, { success: false, error: 'Select a member' });
if (!days) return fail(500, { success: false, error: 'Select a day' });
try {
const crabfit = await getCrabfitData();
const members = await filterMemberData(selectedMembers, crabfit);
const options = findMeetingOptions(members, { days, number });
return { success: true, options };
} catch (err) {
console.error('findMeetingOptions error:', err);
return fail(500, { success: false, error: 'Failed to fetch meetings' });
}
}
};

206
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,206 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Copy } from '@lucide/svelte';
import type { PageProps } from './$types';
import ClickSpark from '$lib/components/ClickSpark.svelte';
let { data }: PageProps = $props();
// let members: string[] = $state(data.members);
let selectedDays: string[] = $state([]);
let lastSelectedDays = $state([]);
let selectedMembers: string[] = $state([]);
let number = $state(1);
let form: HTMLFormElement;
let noMeetings = $state(false);
let meetingOptions: { [optionName: string]: [string, string] }[] = $state([]);
let sparkRef;
const daysOfTheWeek = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday'
];
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
function updateMeetings() {
noMeetings = false;
if (selectedDays.length > 0 && selectedMembers.length > 0) {
if (form) form.requestSubmit();
} else {
meetingOptions = [];
}
}
function sortByDay(options) {
const grouped = {};
for (const option of options) {
for (const [optName, [day, time]] of Object.entries(option)) {
if (!grouped[day]) grouped[day] = [];
grouped[day].push(time);
}
}
for (const day in grouped) {
grouped[day].sort();
}
return grouped;
}
function formatTime(time: string) {
const t = time.split('');
return `${t[0]}${t[1]}:${t[2]}${t[3]}`;
}
function copyOptions(e) {
const membs = selectedMembers.reduce((prev, curr) => `${prev}, ${curr}`);
let text = `Meeting times for ${membs}\n`;
meetingOptions.forEach((opt, i) => {
const [day, time] = Object.values(opt)[0];
text += `${day} at ${formatTime(time)}\n`;
});
navigator.clipboard.writeText(text);
sparkRef.triggerSpark(e);
}
</script>
<div class="flex flex-col items-center justify-center gap-8 px-8 pt-8 pb-20">
<div>
<form
class="flex flex-wrap gap-8 md:gap-24"
bind:this={form}
id="meeting-form"
method="POST"
action="?/getMeetings"
use:enhance={({ formElement, formData, action, cancel, submitter }) => {
return async ({ result, update }) => {
if (result.type === 'success') {
meetingOptions = result.data.options;
if (meetingOptions.length === 0) noMeetings = true;
}
if (result.type === 'error' || result.type === 'failure') {
meetingOptions = [];
}
};
}}
>
<div class="flex flex-col gap-4">
<ul class="menu-horizontal rounded-box bg-base-200">
<li class="menu-title">Select</li>
<li>
<button
onclick={() => {
if (selectedDays.toString() === weekdays.toString()) {
selectedDays = [...lastSelectedDays];
} else {
lastSelectedDays = [...selectedDays];
selectedDays = [...weekdays];
}
}}
class="btn">Weekdays</button
>
</li>
<li>
<button
onclick={() => {
if (selectedDays.toString() === daysOfTheWeek.toString()) {
selectedDays = [...lastSelectedDays];
} else {
lastSelectedDays = [...selectedDays];
selectedDays = [...daysOfTheWeek];
}
}}
class="btn">All</button
>
</li>
<li>
<button
onclick={() => {
if (selectedDays.toString() === '') {
selectedDays = [...lastSelectedDays];
} else {
lastSelectedDays = [...selectedDays];
selectedDays = [];
}
}}
class="btn">None</button
>
</li>
</ul>
<fieldset id="day-select" class="fieldset flex flex-col gap-3">
<legend class="fieldset-legend text-xl">Which days</legend>
{#each daysOfTheWeek as day}
<label class="label gap-3 text-xl text-base-content select-none"
><input
class="checkbox checkbox-lg checkbox-secondary"
type="checkbox"
bind:group={selectedDays}
onchange={updateMeetings}
name="selectedDays"
value={day}
/>{day}</label
>
{/each}
</fieldset>
</div>
<fieldset id="member-select" class="fieldset flex flex-col gap-3">
<legend class="fieldset-legend text-xl">Which members</legend>
{#each data.members as member}
<label class="label gap-3 text-xl text-base-content select-none"
><input
class="checkbox checkbox-lg checkbox-primary"
type="checkbox"
bind:group={selectedMembers}
onchange={updateMeetings}
value={member}
name="selectedMembers"
/>{member}</label
>
{/each}
</fieldset>
</form>
</div>
<div class="divider"><h2 class="text-2xl">Possible meeting times</h2></div>
<div>
{#if meetingOptions.length > 0}
<button class="btn absolute right-5 sm:right-20" onclick={copyOptions}><Copy /> </button>
<div class="flex flex-wrap justify-center gap-8 md:gap-4">
{#each Object.entries(sortByDay(meetingOptions)) as [day, times]}
<ul class="list h-fit w-56 rounded-box bg-base-200 shadow-md sm:w-fit">
<h3 class="divide-y-2 rounded-t-box bg-base-300 px-8 py-4 text-center text-2xl">
{day}
</h3>
{#each times as time}
<li
class="list-row flex justify-center divide-y-2 rounded-box text-2xl font-extralight tabular-nums"
>
{formatTime(time)}
</li>
{/each}
</ul>
{/each}
</div>
{:else if noMeetings}
<p class="text-lg font-semibold">No meeting options found</p>
{:else}
<p class="text-lg font-semibold">Select days and members first</p>
{/if}
</div>
</div>
<ClickSpark bind:this={sparkRef} />

View File

@ -0,0 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit';
import { getCrabfitData } from '$lib/crabfitData';
export const GET: RequestHandler = async () => {
const crabfitData = await getCrabfitData();
const names = crabfitData.map((m) => m.name);
return new Response(JSON.stringify({ names }), {
headers: { 'Content-Type': 'application/json' }
});
};

96
src/routes/page.html Normal file
View File

@ -0,0 +1,96 @@
<script lang="ts">
import { options } from '../../.svelte-kit/generated/server/internal.js';
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
let members: string[] = $state([]);
let selectedDay = $state('Mon');
let selectedMembers: string[] = $state([]);
let number = $state(1);
let form: HTMLFormElement;
let meetingOptions: { [optionName: string]: [string, string] }[] = $state([]);
// Fetch members on mount
onMount(async () => {
const res = await fetch('/api/members');
const data = await res.json();
members = data.names;
});
function updateMeetings() {
if (form) form.requestSubmit();
}
</script>
<form
bind:this="{form}"
id="meeting-form"
method="POST"
action="?/getMeetings"
use:enhance="{({"
formElement,
formData,
action,
cancel,
submitter
})=""
>
{ return async ({ result, update }) => { if (result.type === 'success') { meetingOptions =
result.data; } if (result.type === 'error' || result.type === 'failure') { meetingOptions = []; }
}; }} >
<div>
<label for="day-select">Select day of week:</label>
<select id="day-select" name="days" bind:value="{selectedDay}" onchange="{updateMeetings}">
<option>Mon</option>
<option>Tue</option>
<option>Wed</option>
<option>Thu</option>
<option>Fri</option>
<option>Sat</option>
<option>Sun</option>
</select>
</div>
<div>
<label>Select members:</label>
{#if members.length === 0}
<p>Loading members...</p>
{:else}
<select multiple bind:value="{members}">
{#each members as member}
<option value="{member}">{member}</option>
{/each}
</select>
{/if}
</div>
<div>
<label for="number-input">Number of meetings:</label>
<input
id="number-input"
type="number"
name="number"
min="1"
bind:value="{number}"
onchange="{updateMeetings}"
/>
</div>
<!-- Hidden inputs for comma-separated values -->
<!-- <input type="hidden" name="members" value={selectedMembers.join(',')} />
<input type="hidden" name="days" value={selectedDay} /> -->
</form>
<div>
<h2>Possible meeting times:</h2>
{#if meetingOptions.length > 0}
<ul>
{#each meetingOptions as option, i} {#each Object.entries(option) as [optName, [day, time]]}
<li><strong>{optName}</strong>: {day} at {time}</li>
{/each} {/each}
</ul>
{:else}
<p>No meeting options found.</p>
{/if}
</div>

107
src/test/meeting.test.ts Normal file
View File

@ -0,0 +1,107 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchCrabfitData, filterMemberData, findMeetingOptions } from '$lib/meeting';
// vi.mock('node-fetch', async () => ({
// default: vi.fn(() =>
// Promise.resolve({
// json: () => Promise.resolve(mockData)
// })
// )
// }));
const mockData = [
{
name: 'Alice',
availability: ['1100-1', '1115-1', '1130-1', '1100-2'],
created_at: 1000000000
},
{
name: 'Bob',
availability: ['1100-1', '1400-1', '1500-1'],
created_at: 1000000001
},
// Charlie has no times on Monday, but does on Tuesdays
{
name: 'Charlie',
availability: ['2000-1', '1100-2'],
created_at: 1000000002
}
];
const testData = [
{
name: 'B',
availability: ['0900-1', '0915-1', '0930-1', '0945-1', '1000-1', '1015-1', '1030-1', '1045-1'],
created_at: 1753176466
},
{
name: 'A',
availability: ['0800-1', '0815-1', '0830-1', '0845-1', '0900-1', '0915-1', '0930-1', '0945-1'],
created_at: 1753176450
}
];
const CRABFIT_TEST_API_URL = 'https://api.crab.fit/event/schedulertestdata-850919';
describe('findMeetingOptions', () => {
it('returns options correctly', async () => {
const options = await findMeetingOptions([mockData[0], mockData[1]], {
days: ['Monday'],
number: 1
});
expect(options).toEqual([{ 'Option 1': ['Monday', '1100'] }]);
});
it('returns options correctly', async () => {
const options = await findMeetingOptions([mockData[0], mockData[2]], {
days: ['Tuesday'],
number: 1
});
expect(options).toEqual([{ 'Option 1': ['Tuesday', '1100'] }]);
});
it('returns no options for impossible constraints', async () => {
const options = await findMeetingOptions([mockData[0], mockData[2]], {
days: ['Monday'],
number: 1
});
expect(options).toEqual([]);
});
});
describe('filterMemberData', () => {
it('filters member data', async () => {
const members = ['A', 'B'];
const result = await filterMemberData(members, testData);
expect(result).toEqual(expect.arrayContaining(testData));
});
});
describe('fetchCrabfitData', () => {
it('gets member data', async () => {
expect(await fetchCrabfitData(CRABFIT_TEST_API_URL)).toEqual(testData);
});
});
describe('integration', () => {
it('finds meetings with fetched data', async () => {
const crabfitData = await fetchCrabfitData(CRABFIT_TEST_API_URL);
const members = ['A', 'B'];
const memberData = await filterMemberData(members, crabfitData);
const options = await findMeetingOptions(memberData, {
days: ['Monday'],
number: 1
});
expect(options).toEqual([{ 'Option 1': ['Monday', '0900'] }]);
});
it('finds meetings with test data', async () => {
const members = ['A', 'B'];
const memberData = await filterMemberData(members, testData);
const options = await findMeetingOptions(memberData, {
days: ['Monday'],
number: 1
});
expect(options).toEqual([{ 'Option 1': ['Monday', '0900'] }]);
});
});