Rahvatarkus Better UI and errors

Handle more edgecases
More specific error messages
All error messages in Estonian
This commit is contained in:
Mihkel Martin Kasterpalu 2025-02-11 20:17:49 +02:00
parent 35ad2da1c3
commit f26aa8f5c7
15 changed files with 243 additions and 74 deletions

View file

@ -0,0 +1,28 @@
import Root from "./textarea.svelte";
type FormTextareaEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLTextAreaElement;
};
type TextareaEvents = {
blur: FormTextareaEvent<FocusEvent>;
change: FormTextareaEvent<Event>;
click: FormTextareaEvent<MouseEvent>;
focus: FormTextareaEvent<FocusEvent>;
keydown: FormTextareaEvent<KeyboardEvent>;
keypress: FormTextareaEvent<KeyboardEvent>;
keyup: FormTextareaEvent<KeyboardEvent>;
mouseover: FormTextareaEvent<MouseEvent>;
mouseenter: FormTextareaEvent<MouseEvent>;
mouseleave: FormTextareaEvent<MouseEvent>;
paste: FormTextareaEvent<ClipboardEvent>;
input: FormTextareaEvent<InputEvent>;
};
export {
Root,
//
Root as Textarea,
type TextareaEvents,
type FormTextareaEvent,
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { WithElementRef, WithoutChildren } from "bits-ui";
import type { HTMLTextareaAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
bind:value
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...restProps}
></textarea>

View file

@ -0,0 +1,73 @@
import { dev } from '$app/environment';
import getPoolSize from './getPoolSize';
// Based on code generated by Claude 3.5 Sonnet
class QuestionBalanceStore {
private balances: Map<string, number>;
private lastAccessed: Map<string, number>;
constructor() {
this.balances = new Map();
this.lastAccessed = new Map();
// Clean up expired sessions every hour
setInterval(() => this.cleanup(), 1000 * 60 * 60);
}
// Get remaining questions for a session
getBalance(sessionToken: string): number {
this.lastAccessed.set(sessionToken, Date.now());
return this.balances.get(sessionToken) || 0;
}
// Add questions to a session (e.g., after answering)
addQuestions(sessionToken: string, count: number = 1): void {
this.lastAccessed.set(sessionToken, Date.now());
const currentBalance = this.balances.get(sessionToken) || 0;
this.balances.set(sessionToken, currentBalance + count);
}
// Use a question from the balance
async useQuestion(sessionToken: string): Promise<boolean> {
this.lastAccessed.set(sessionToken, Date.now());
const currentBalance = this.balances.get(sessionToken) || 0;
// New users can neither ask nor answer in this case
// Allow one question for this edge case
if (currentBalance === 0) {
const poolSize = await getPoolSize();
return poolSize === 0;
}
this.balances.set(sessionToken, currentBalance - 1);
return true;
}
// Clean up sessions that haven't been accessed in 24 hours
private cleanup(): void {
const now = Date.now();
const expiryTime = 24 * 60 * 60 * 1000; // 24 hours
for (const [token, lastAccess] of this.lastAccessed.entries()) {
if (now - lastAccess > expiryTime) {
this.balances.delete(token);
this.lastAccessed.delete(token);
}
}
}
// For debugging in development
debugInfo(): unknown {
if (!dev) return null;
return {
totalSessions: this.balances.size,
sessions: Object.fromEntries(this.balances),
lastAccessed: Object.fromEntries(this.lastAccessed)
};
}
}
// Create singleton instance
export const questionBalanceStore = new QuestionBalanceStore();

View file

@ -0,0 +1,13 @@
import { db } from '$lib/server/db';
import { questions } from '$lib/server/db/schema';
import { sql } from 'drizzle-orm';
export default async () => {
const results = await db
.select({
poolSize: sql`COUNT(CASE WHEN answer_count < 5 THEN 1 END)`
})
.from(questions);
return Number(results[0].poolSize);
};

View file

@ -1,7 +1,10 @@
import { json } from '@sveltejs/kit';
import { eq, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { questions, answers } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
const maxAnswers = 5;
@ -29,17 +32,17 @@ export async function POST({ locals, request }) {
.limit(1);
if (!question.length) {
return json({ error: 'Question not found' }, { status: 400 });
return json({ error: 'Seda küsimust ei leitud' }, { status: 400 });
}
const [questionData] = question;
// if (questionData.creator === user) {
// return json({ error: 'Cannot answer own question' }, { status: 400 });
// }
if (questionData.creator === user) {
return json({ error: 'Iseendale vastamine ei ole produktiivne' }, { status: 400 });
}
if (questionData.answerCount >= maxAnswers) {
return json({ error: 'No more answers needed' }, { status: 400 });
return json({ error: 'Sellel küsimusel on juba piisavalt küsimusi' }, { status: 400 });
}
// Insert answer and update count atomically
@ -57,6 +60,9 @@ export async function POST({ locals, request }) {
.set({ answerCount: sql`${questions.answerCount} + 1` })
.where(eq(questions.id, questionId));
// Valid answer, allow this user to ask one question
questionBalanceStore.addQuestions(user);
return json(newAnswer);
});
}

View file

@ -1,16 +1,8 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { questions } from '$lib/server/db/schema';
import { sql } from 'drizzle-orm';
import getPoolSize from '$lib/server/rahvatarkus/getPoolSize';
export async function GET() {
const results = await db
.select({
poolSize: sql`COUNT(CASE WHEN answer_count < 5 THEN 1 END)`
})
.from(questions);
const size = await getPoolSize();
return json({
size: Number(results[0].poolSize)
});
return json({ size });
}

View file

@ -1,7 +1,10 @@
import { json } from '@sveltejs/kit';
import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { questions, answers } from '$lib/server/db/schema';
import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm';
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
export async function GET({ locals }) {
const { session } = locals;
@ -19,6 +22,7 @@ export async function GET({ locals }) {
.from(questions)
.where(
and(
not(eq(questions.creator, user)),
lt(questions.answerCount, 5),
not(
exists(
@ -34,7 +38,7 @@ export async function GET({ locals }) {
.limit(1);
if (!eligibleQuestions.length) {
return json({ error: 'No questions available' }, { status: 404 });
return json({ error: '' }, { status: 404 });
}
return json(eligibleQuestions[0]);
@ -52,7 +56,7 @@ export async function POST({ locals, request }) {
}
if (!content?.trim()) {
return json({ error: 'Content is required' }, { status: 400 });
return json({ error: 'Küsimustel on sisu vaja' }, { status: 400 });
}
// Normalize content
@ -71,7 +75,7 @@ export async function POST({ locals, request }) {
.limit(1);
if (existingQuestion.length > 0) {
throw new Error('Question already exists');
throw new Error('Seda on juba küsitud');
}
// Check user's recent questions (optional rate limiting)
@ -86,7 +90,11 @@ export async function POST({ locals, request }) {
);
if (recentQuestions[0].count >= 10) {
throw new Error('Too many questions in the last hour');
throw new Error('Rahu rahu! Oled tunni aja jooksul liiga palju küsimusi esitanud');
}
if (!questionBalanceStore.useQuestion(user)) {
throw new Error('Pead vastama teistele enne küsimist');
}
// Insert the new question
@ -103,7 +111,7 @@ export async function POST({ locals, request }) {
return json(newQuestion);
} catch (e) {
const error = e instanceof Error ? e.message : 'Failed to create question';
const error = e instanceof Error ? e.message : 'Küsimine põrus';
return json({ error }, { status: 400 });
}
}

View file

@ -6,6 +6,7 @@ import { formSchema as answerSchema } from './answer-schema';
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { nanoid } from 'nanoid';
import { questionBalanceStore } from '$lib/server/rahvatarkus/QuestionBalance';
export const load: LayoutServerLoad = async ({ fetch, locals }) => {
const { session } = locals;
@ -17,6 +18,8 @@ export const load: LayoutServerLoad = async ({ fetch, locals }) => {
const user = session.data.userId;
const userBalance = questionBalanceStore.getBalance(user);
let question: Question | undefined = undefined;
const poolSize = await fetch('/api/rahvatarkus/pool')
@ -43,7 +46,10 @@ export const load: LayoutServerLoad = async ({ fetch, locals }) => {
}
return {
user: user,
user: {
id: user,
balance: userBalance
},
question: question,
poolSize,
question_form: await superValidate(zod(questionSchema)),

View file

@ -3,15 +3,18 @@
import QuestionForm from './question-form.svelte';
import AnswerForm from './answer-form.svelte';
import { onMount } from 'svelte';
let { data, children } = $props();
$inspect(data);
let firstTab = $state('answer');
let firstTab = $derived(data?.question ? 'answer' : 'question');
onMount(() => {
firstTab = data?.question ? 'answer' : 'question';
});
</script>
<Tabs.Root value={firstTab} class="flex w-full max-w-md flex-col items-center gap-2">
<Tabs.Root value={firstTab} class="flex w-full max-w-md flex-col items-center gap-1">
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="answer">Vasta</Tabs.Trigger>
<Tabs.Trigger value="question">Küsi</Tabs.Trigger>

View file

@ -9,7 +9,7 @@ import { fail } from '@sveltejs/kit';
const pageSize = 5;
export const load: PageServerLoad = async ({ fetch, url }) => {
const page = Number(url.searchParams.get('leht')) || 0;
const page = Number(url.searchParams.get('leht')) || 1;
const archiveRes = fetch(`/api/rahvatarkus/archive/${pageSize}/${(page - 1) * pageSize}`)
.then((res) => {
@ -63,12 +63,12 @@ export const actions: Actions = {
});
if (!response.ok) {
const errorMessage = response.data?.error;
if (form.errors.answer) {
form.errors.answer.push(errorMessage);
} else {
form.errors.answer = [errorMessage];
if (response.data?.error) {
if (form.errors.answer) {
form.errors.answer.push(response.data.error);
} else {
form.errors.answer = [response.data.error];
}
}
return fail(400, {
@ -111,13 +111,11 @@ export const actions: Actions = {
});
if (!response.ok) {
if (response.data?.error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
const errorMessage = 'Sellel küsimusel on juba vastus.';
if (response.data?.error) {
if (form.errors.question) {
form.errors.question.push(errorMessage);
form.errors.question.push(response.data.error);
} else {
form.errors.question = [errorMessage];
form.errors.question = [response.data.error];
}
}

View file

@ -16,7 +16,7 @@
const siblingCount = $derived(isDesktop.current ? 1 : 0);
</script>
<header class="mb-12 mt-32 flex flex-col items-center text-center font-title">
<header class="mb-12 mt-24 flex flex-col items-center text-center font-title">
<h1 class="mb-1 scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
Mida rahvas teab?
</h1>

View file

@ -9,6 +9,7 @@
import * as Card from '$lib/components/ui/card/index.js';
import * as Form from '$lib/components/ui/form/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
data
@ -28,30 +29,25 @@
}
});
const { form: formData, enhance } = form;
const { form: formData, enhance, constraints } = form;
</script>
<Card.Root>
<Card.Header>
<Card.Title>Vasta vajajale</Card.Title>
<Card.Description>
{#if !data.question}
{#if data.poolSize === 0}
Rahval said kõik küsimused otsa!
{:else}
Oled kõigile vastanud, kuid enda küsimustel vastused puudu.
{/if}
{:else}
Tänutäheks saad vastu ühe küsimuse küsida.
{/if}
</Card.Description>
{#if !data.question}
<Card.Title>Kõik vastatud</Card.Title>
<Card.Description>Rahval said kõik küsimused otsa!</Card.Description>
{:else}
<Card.Title>Vasta vajajale</Card.Title>
<Card.Description>Tänutäheks saad vastu ühe küsimuse küsida.</Card.Description>
{/if}
</Card.Header>
{#if !data.question}
<Card.Content>
{#if data.poolSize === 0}
<p class="text-sm leading-6">Sellise erandjuhuga saad ühe korra niisama küsida!</p>
{:else}
<p class="text-sm leading-6">Äkki tahaksid su sõbrad vastata või on meil mingi küsimus?</p>
<p class="text-sm leading-6">Äkki tahaksid su sõbrad vastata või on neil küsimusi?</p>
{/if}
</Card.Content>
{:else}
@ -61,7 +57,7 @@
<Form.Control>
{#snippet children({ props })}
<Form.Label>{data.question.content}?</Form.Label>
<Input {...props} bind:value={$formData.answer} />
<Textarea {...props} bind:value={$formData.answer} class="resize-none" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
@ -72,6 +68,9 @@
<Input type="hidden" {...props} bind:value={$formData.questionId} />
{/snippet}
</Form.Control>
<Form.Description class="text-right">
{$formData.answer.length}/{$constraints.answer?.maxlength}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
</Card.Content>

View file

@ -2,7 +2,10 @@ import { z } from 'zod';
export const formSchema = z.object({
questionId: z.string().length(21),
answer: z.string().min(2).max(250)
answer: z
.string()
.min(2, 'Vastus peab olema vähemalt 2 tähemärki.')
.max(150, 'Vastus ei või olla pikem kui 150 tähemärki.')
});
export type FormSchema = typeof formSchema;

View file

@ -17,12 +17,15 @@
question_form: SuperValidated<Infer<FormSchema>>;
question: Question;
poolSize: number;
user: {
id: string;
balance: number;
};
};
} = $props();
const form = superForm(data.question_form, {
validators: zodClient(formSchema),
invalidateAll: false,
resetForm: true,
onUpdated: ({ form: f }) => {
if (f.valid) {
@ -33,28 +36,40 @@
}
});
const { form: formData, enhance } = form;
const { form: formData, enhance, constraints } = form;
</script>
<Card.Root>
<Card.Header>
<Card.Title>Küsi rahvalt</Card.Title>
<Card.Description>Sul on alles 0 küsimust. Vasta teistele kõigepealt!</Card.Description>
<Card.Description>Iga vastus annab võimaluse küsida ühe küsimuse.</Card.Description>
</Card.Header>
<form method="POST" use:enhance action="?/question">
{#if data.user.balance === 0 && (!data.question || data.poolSize > 0)}
<Card.Content>
<Form.Field {form} name="question">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Küsimus rahvale</Form.Label>
<Input {...props} bind:value={$formData.question} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<p class="text-sm leading-6">Enne küsimist pead kõigepealt vastama teistele!</p>
</Card.Content>
<Card.Footer>
<Form.Button>Küsi</Form.Button>
</Card.Footer>
</form>
{:else}
<form method="POST" use:enhance action="?/question">
<Card.Content>
<Form.Field {form} name="question">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Küsimus rahvale</Form.Label>
<Input {...props} bind:value={$formData.question} />
{/snippet}
</Form.Control>
<Form.Description class="flex justify-between">
<p>
Sul on alles {data.user.balance > 0 ? data.user.balance : data.poolSize > 0 ? 0 : 1} küsimust.
</p>
<p>{$formData.question.length}/{$constraints.question?.maxlength}</p>
</Form.Description>
<Form.FieldErrors />
</Form.Field>
</Card.Content>
<Card.Footer>
<Form.Button>Küsi</Form.Button>
</Card.Footer>
</form>
{/if}
</Card.Root>

View file

@ -1,7 +1,10 @@
import { z } from 'zod';
export const formSchema = z.object({
question: z.string().min(2).max(50)
question: z
.string()
.min(2, 'Küsimus peab olema vähemalt 2 tähemärki.')
.max(50, 'Küsimus ei või olla pikem kui 50 tähemärki.')
});
export type FormSchema = typeof formSchema;