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

17
.dockerignore Normal file
View File

@ -0,0 +1,17 @@
Dockerfile
.dockerignore
.git
.gitignore
.gitattributes
README.md
.npmrc
.prettierrc
.eslintrc.cjs
.graphqlrc
.editorconfig
.svelte-kit
.vscode
node_modules
build
package
**/.env

1
.env.example Normal file
View File

@ -0,0 +1 @@
CRABFIT_API_URL=https://api.crab.fit/event/cfschedulerdemo-282854

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=false

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN npm install -g corepack@0.24.1 && corepack enable
# Copy deps first for caching
COPY pnpm-lock.yaml package.json ./
RUN pnpm i --frozen-lockfile
# Copy rest of app
COPY . .
# Build
RUN pnpm run build
RUN pnpm prune --production
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
ENV NODE_ENV=production
CMD [ "node", "build" ]

37
README.md Normal file
View File

@ -0,0 +1,37 @@
## Completely Fair™ Scheduler Webapp
The CF scheduler helps you find time slots that you have in common with others. This is helpful when you for example have coworkers from different parts of the world.
Originally inspired by the python-based [completely-fair-scheduler](https://git.autonomic.zone/autonomic-cooperative/completely-fair-scheduler), this webapp is more accessible as it does not require the use of a terminal.
## How to use
### 1. Create a crabfit
Make an event on your chosen crabfit instance, in our case https://crab.fit/
Except the name, use these settings when creating the event:
![crabift-settings](crabfit-settings.png)
Get your teammates to fill it out!
### 2. Set the CRABFIT_API_URL
Get the event ID. It's at the end of the url of the event. In the demo url it's `cfschedulerdemo-282854`. Put `https://api.crab.fit/event/` in front of it and you have the API url, like so: `https://api.crab.fit/event/cfschedulerdemo-282854`
### 3. Start the application
#### docker-compose (suggested)
```
docker compose up
```
#### pnpm
```
pnpm install
pnpm build
pnpm preview
```
### 4. You're done!
Your webapp should be running on [127.0.0.1:3000](127.0.0.1:3000). It should look something like this:
![demo](demo.png)

BIN
crabfit-settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
version: '3.8'
services:
nodejs:
restart: unless-stopped
build:
context: ./
dockerfile: Dockerfile
environment:
- CRABFIT_API_URL=${CRABFIT_API_URL}
ports:
- '3000:3000'
deploy:
update_config:
failure_action: rollback
order: start-first

40
eslint.config.js Normal file
View File

@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "completely-fair-scheduler-webapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview --port 3000",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.31.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"daisyui": "^5.0.46",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"mathjs": "^14.5.3",
"node-fetch": "^3.3.2",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
"vitest": "^3.2.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"dependencies": {
"@lucide/svelte": "^0.525.0",
"build": "^0.1.4",
"pnpm": "^10.14.0",
"preview": "^0.1.3"
}
}

3486
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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'] }]);
});
});

1
static/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

20
svelte.config.js Normal file
View File

@ -0,0 +1,20 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ out: 'build' }),
env: {
dir: "./"
},
alias: {
$src: "./src",
}
},
};
export default config;

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

20
vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
projects: [
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});