Squash commmits
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal 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
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
CRABFIT_API_URL=https://api.crab.fit/event/cfschedulerdemo-282854
|
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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-*
|
9
.prettierignore
Normal file
9
.prettierignore
Normal 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
16
.prettierrc
Normal 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
27
Dockerfile
Normal 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
37
README.md
Normal 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|

|
||||||
|
|
||||||
|
|
BIN
crabfit-settings.png
Normal file
BIN
crabfit-settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal 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
40
eslint.config.js
Normal 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
55
package.json
Normal 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
3486
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
src/app.css
Normal file
4
src/app.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: cmyk --default, dracula --prefersdark;
|
||||||
|
}
|
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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
7
src/demo.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
79
src/lib/components/ClickSpark.svelte
Normal file
79
src/lib/components/ClickSpark.svelte
Normal 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
17
src/lib/crabfitData.ts
Normal 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
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
108
src/lib/meeting.ts
Normal file
108
src/lib/meeting.ts
Normal 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);
|
||||||
|
}
|
7
src/routes/+layout.svelte
Normal file
7
src/routes/+layout.svelte
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
38
src/routes/+page.server.ts
Normal file
38
src/routes/+page.server.ts
Normal 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
206
src/routes/+page.svelte
Normal 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} />
|
12
src/routes/api/members/+server.ts
Normal file
12
src/routes/api/members/+server.ts
Normal 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
96
src/routes/page.html
Normal 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
107
src/test/meeting.test.ts
Normal 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
1
static/favicon.svg
Normal 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
20
svelte.config.js
Normal 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
19
tsconfig.json
Normal 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
20
vite.config.ts
Normal 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}']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
Reference in New Issue
Block a user