diff --git a/.gitignore b/.gitignore index 3b462cb..eaa6c19 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Databases +*.sqlite diff --git a/bun.lockb b/bun.lockb index 215688a..9d988b5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/UserDatabase.uml b/docs/UserDatabase.uml new file mode 100644 index 0000000..94fee8a --- /dev/null +++ b/docs/UserDatabase.uml @@ -0,0 +1,10 @@ +@startuml + +entity "users" { + id: primary + -- + name: text + database_location: text +} + +@enduml diff --git a/docs/UserEntryDatabase.svg b/docs/UserEntryDatabase.svg new file mode 100644 index 0000000..40ebef8 --- /dev/null +++ b/docs/UserEntryDatabase.svg @@ -0,0 +1 @@ +recordsid: primarydate: varchar(10) [YYYY-MM-DD]start: varchar(5) [HH:MM]end: varchar(5) [HH:MM] created: timestampmodified: timestampmodified_to: records::idestimatesid: primarydate: varchar(7) [YYYY-MM]estimate: real created: timestampmodified: timestampmodified_to: estimates::id \ No newline at end of file diff --git a/docs/UserEntryDatabase.uml b/docs/UserEntryDatabase.uml new file mode 100644 index 0000000..3644cab --- /dev/null +++ b/docs/UserEntryDatabase.uml @@ -0,0 +1,28 @@ +@startuml + +skinparam linetype ortho + +entity "records" { + id: primary + -- + date: varchar(10) [YYYY-MM-DD] + start: varchar(5) [HH:MM] + end: varchar(5) [HH:MM] + + created: timestamp + modified: timestamp + modified_to: records::id +} + +entity "estimates" { + id: primary + -- + date: varchar(7) [YYYY-MM] + estimate: real + + created: timestamp + modified: timestamp + modified_to: estimates::id +} + +@enduml diff --git a/package.json b/package.json index d39b605..baa9555 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,19 @@ { "name": "stundenaufzeichnung", - "private": true, "version": "0.0.1", - "type": "module", + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/adapter-node": "^5.2.11", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/sqlite3": "^3.1.11", + "svelte": "^5.0.0", + "svelte-adapter-bun": "^0.5.2", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "private": true, "scripts": { "dev": "vite dev", "build": "vite build", @@ -10,13 +21,5 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, - "devDependencies": { - "@sveltejs/adapter-auto": "^3.0.0", - "@sveltejs/kit": "^2.9.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "typescript": "^5.0.0", - "vite": "^6.0.0" - } + "type": "module" } diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..5e4d66b --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,38 @@ + +import { User, UserDB, init_db, close_db, create_user, get_user, get_user_db } from "$lib/server/database"; + +import { Database } from "bun:sqlite"; + +function init() { + + init_db(); + + console.log("started"); + +} + +function deinit() { + close_db(); + console.log('exit'); +} + +init(); + +process.on('exit', (reason) => { + deinit(); + process.exit(0); +}); + +process.on('SIGINT', (reason) => { + console.log("SIGINT") + process.exit(0); +}) + + +export async function handle({ event, resolve }) { + + event.locals.user = get_user(); + console.log("handle"); + + return await resolve(event); +} diff --git a/src/lib/db_types.ts b/src/lib/db_types.ts new file mode 100644 index 0000000..60b5032 --- /dev/null +++ b/src/lib/db_types.ts @@ -0,0 +1,20 @@ + +import { Database } from 'bun:sqlite'; + +export interface UserEntry { + id: number; + name: string; + created: string; +} + +export interface RecordEntry { + id: number; + date: string; + start: string; + end: string; + comment: string; + created: string; + modified: string; + modified_to: number; +} + diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts new file mode 100644 index 0000000..c403183 --- /dev/null +++ b/src/lib/server/database.ts @@ -0,0 +1,233 @@ +import { mkdir } from "node:fs/promises"; +import { Database, SQLiteError } from "bun:sqlite"; + +import { UserEntry, RecordEntry } from "$lib/db_types"; +import { parseDate, isTimeValidHHMM } from "$lib/util"; + +const DATABASES_PATH: string = ""; +const USER_DATABASE_PATH: string = DATABASES_PATH + "users.sqlite"; + +const CHECK_QUERY: string = + "SELECT * FROM sqlite_master;"; + +const USER_DATABASE_SETUP: string[] = [ + "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT, created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL);", +] + +const USER_DATABASE_ADD_USER: string = + "INSERT INTO users (name) VALUES ($name);"; + +const USER_DATABASE_GET_USER: string = + "SELECT * FROM users;"; + +const ENTRY_DATABASE_SETUP: string[] = [ + "PRAGMA foreign_keys = ON;", + + "CREATE TABLE meta (key TEXT PRIMARY KEY NOT NULL, value NUMBER);", + "INSERT INTO meta(key, value) VALUES ('triggerActive', 1)", + + "CREATE TABLE records ( \ + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, \ + date VARCHAR(10), \ + start VARCHAR(5), \ + end VARCHAR(5), \ + comment TEXT, \ + created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \ + modified DATETIME DEFAULT NULL, \ + modified_to INTEGER UNIQUE DEFAULT NULL, \ + FOREIGN KEY(modified_to) REFERENCES records(id) \ + );", + + `CREATE TRIGGER prevent_update_if_superseded + BEFORE UPDATE ON records + WHEN (SELECT value FROM meta WHERE key = 'triggerActive') = 1 + AND (OLD.modified_to NOT NULL OR OLD.date ISNULL) + BEGIN + SELECT raise(ABORT, 'Modification on changed row is not allowed'); + END;`, + + "CREATE TRIGGER prevent_update \ + BEFORE UPDATE ON records \ + WHEN (SELECT value FROM meta WHERE key = 'triggerActive') = 1 \ + BEGIN \ + INSERT INTO records(date, start, end, comment) VALUES (NEW.date, NEW.start, NEW.end, NEW.comment); \ + UPDATE records SET (modified, modified_to) = (CURRENT_TIMESTAMP, last_insert_rowid()) WHERE NEW.id == id; \ + SELECT raise(IGNORE); \ + END;", + + `CREATE TRIGGER prevent_delete + BEFORE DELETE ON records + BEGIN + UPDATE records SET (date, start, end, comment) = (null, null, null, null) WHERE OLD.id = id; + SELECT raise(IGNORE); + END;` +] + +const ENTRY_DATABASE_GET_ENTRY_BY_ID: string = + "SELECT * FROM records WHERE modified_to ISNULL AND id = $id;" + +const ENTRY_DATABASE_GET_ENTRIES: string = + "SELECT * FROM records WHERE modified_to ISNULL;" + +const ENTRY_DATABASE_ADD_ENTRY: string = + "INSERT INTO records(date, start, end, comment) VALUES ($date, $start, $end, $comment);" + +const Entry_DATABASE_EDIT_ENTRY: string = + "UPDATE records SET date = $date, start = $start, end = $end, comment = $comment WHERE id = $id;"; + + +export class User { + user: UserEntry; + private database: Database; + + constructor(user: UserEntry, db: Database) { + this.user = user; + this._database = db; + } + + get_entries(): Entry[] { + const query = this._database.query(ENTRY_DATABASE_GET_ENTRIES); + const res = query.all() + + return res; + } + + get_entry(id: number): Entry { + const query = this._database.query(ENTRY_DATABASE_GET_ENTRY_BY_ID); + const res = query.get({ id: id }); + + return res; + } + + insert_entry(date: string, start: string, end: string, comment: string | null) { + + if (parseDate(date) == null || !isTimeValidHHMM(start) || !isTimeValidHHMM(end)) { + return false; + } + + const query = this._database.query(ENTRY_DATABASE_ADD_ENTRY); + const res = query.run({ date: date, start: start, end: end, comment: comment }); + + return res.changes == 1; + } + + update_entry(id: number, ndate: string, nstart: string, nend: string, ncomment: string): RecordEntry | null { + + if (isNaN(id) || parseDate(ndate) == null || !isTimeValidHHMM(nstart) || !isTimeValidHHMM(nend)) { + return null; + } + + const query = this._database.query(Entry_DATABASE_EDIT_ENTRY); + const res = query.run({ id: id, date: ndate, start: nstart, end: nend, comment: ncomment }); + + return res.changes > 1; + } +} + + +let user_database: Database; + + +function is_db_initialized(db: Database): boolean { + try { + + let res = db.query(CHECK_QUERY).get(); + + return res != null; + } catch (exception) { + if (!(exception instanceof SQLiteError)) { + throw exception; + } + + console.log(e); + + return false; + } +} + +function get_user_db_name(user: UserEntry) { + return DATABASES_PATH + "user-" + user.id + ".sqlite" +} + +function setup_db(db: Database, setup_queries: string[]) { + setup_queries.forEach((q) => { db.query(q).run(); }); +} + +export function init_db() { + + user_database = new Database(USER_DATABASE_PATH, { strict: true, create: true }); + + if (!is_db_initialized(user_database)) { + setup_db(user_database, USER_DATABASE_SETUP); + } + +} + +export function close_db() { + if (user_database) { + user_database.close(); + } +} + +export function create_user(name: string): boolean { + + try { + const statement = user_database.query(USER_DATABASE_ADD_USER); + const result = statement.run({ name: name }); + + return true; + } catch (e) { + console.log(e); + if (e instanceof SQLiteError) { + return false; + } + + throw e; + } +} + +function _get_user(): UserEntry { + + try { + const statement = user_database.prepare(USER_DATABASE_GET_USER); + const result: UserEntry = statement.get(); + + if (result == null) { + create_user("PM"); + return get_user(); + } + + return result; + + } catch (e) { + if (e instanceof SQLiteError) { + return false; + } + + throw e; + } + +} + +export function get_user(): User | null { + + const user = _get_user(); + + const db_name = get_user_db_name(user); + + try { + + let userdb = new Database(db_name, { create: true, strict: true }); + + if (!is_db_initialized(userdb)) { + setup_db(userdb, ENTRY_DATABASE_SETUP); + } + + return new User(user, userdb); + } catch (e) { + console.log(e); + + return null; + } + +} diff --git a/src/lib/util.ts b/src/lib/util.ts new file mode 100644 index 0000000..9f461fe --- /dev/null +++ b/src/lib/util.ts @@ -0,0 +1,89 @@ + +export function toInt(str: string): number { + let value = 0; + for (let i = 0; i < str.length; ++i) { + let c = str.charAt(i); + let n = Number(c); + if (isNaN(n)) { + return NaN; + } + value = value*10 + n; + } + + return value; +} + +export function parseDate(str: string): Date | null { + if (str.length != 2+1+2+1+4) { + return null; + } + + let day = toInt(str.slice(0, 2)) + let month = toInt(str.slice(3, 5)); + let year = toInt(str.slice(6, 10)); + + if (isNaN(day) || isNaN(month) || isNaN(year) || str.charAt(2) !== '.' || str.charAt(5) !== '.') { + return null; + } + + let date = new Date(year, month-1, day); + + if (isNaN(date.valueOf())) { + return null; + } + + return date; +} + +export function calculateDuration(start: string, end: string): string { + + if (start.length !== 5 || end.length !== 5) { + return ""; + } + + let start_h = toInt(start.slice(0, 2)); + let start_m = toInt(start.slice(3, 5)); + + let end_h = toInt(end.slice(0, 2)); + let end_m = toInt(end.slice(3, 5)); + + if (isNaN(start_h) || isNaN(start_m) || start.charAt(2) !== ':' + || isNaN(end_h) || isNaN(end_m) || end.charAt(2) !== ':') { + return ""; + } + + let start_n = start_h * 60 + start_m; + let end_n = end_h * 60 + end_m; + + let duration = (end_n - start_n) / 60; + + return duration.toFixed(2); +} + +export function localToIsoDate(str: string): string | undefined { + return parseDate(str)?.toISOString() +} + +export function isoToLocalDate(str: string): string | undefined { + let date = new Date(str); + + if (!date) { + return undefined; + } + + console.log(str); + console.log(date); + + return date.getDate() + "." + date.getMonth() + "." + date.getFullYear(); +} + +export function isTimeValidHHMM(str: string): boolean { + if (str.length !== 5) { + return false; + } + + let h = toInt(str.slice(0, 2)); + let m = toInt(str.slice(3, 5)); + + return (!(isNaN(h) || isNaN(m))) && h < 24 && m < 60 && str.charAt(2) == ':'; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..8131ae6 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,172 @@ +import type { SQLiteError } from "bun:sqlite" + +import { fail, redirect } from '@sveltejs/kit'; + +import { toInt, parseDate, isTimeValidHHMM } from "$lib/util" +import { get_user, User } from "$lib/server/database"; + +export async function load({ locals }) { + return { + records: locals.user.get_entries() + }; +} + +export const actions = { + new_entry: async ({locals, request}) => { + + const ok = "ok"; + const missing = "missing"; + const invalid = "invalid"; + + const data = await request.formData(); + + let date = data.get("date"); + let start = data.get("start"); + let end = data.get("end"); + let comment = data.get("comment"); + + let return_obj = { + date : { + status: ok, + value: date + }, + start : { + status: ok, + value: start + }, + end : { + status: ok, + value: end + }, + comment : { + status: ok, + value: comment + }, + } + + if (date == null) { + return_obj.date.status = missing; + } else if (parseDate(date) == null) { + return_obj.date.status = invalid; + } + + if (start == null) { + return_obj.start.status = missing; + } else if (!isTimeValidHHMM(start)) { + return_obj.start.status = invalid; + } + + if (end == null) { + return_obj.end.status = missing; + } else if (!isTimeValidHHMM(end)) { + return_obj.end.status = invalid; + } + + console.log(return_obj); + + if (Object.keys(return_obj).some((v) => { return return_obj[v].status != ok; })) { + return fail(400, { new_entry: return_obj }); + } + + let res = locals.user.insert_entry(date, start, end, comment); + + if (!res) { + return fail(500, { }) + } + + return { new_entry: { success: true } }; + }, + edit_entry: async ({locals, request}) => { + + const ok = "ok"; + const missing = "missing"; + const invalid = "invalid"; + + const data = await request.formData(); + + let id = data.get("id"); + let date = data.get("date"); + let start = data.get("start"); + let end = data.get("end"); + let comment = data.get("comment"); + + let return_obj = { + id: { + status: ok, + value: id, + }, + date : { + status: ok, + value: date + }, + start : { + status: ok, + value: start + }, + end : { + status: ok, + value: end + }, + comment : { + status: ok, + value: comment + }, + + } + + if (id == null) { + return_obj.id.status = missing; + } else if (isNaN(toInt(id))) { + return_obj.id.status = invalid; + } + + if (date == null) { + return_obj.date.status = missing; + } else if (parseDate(date) == null) { + return_obj.date.status = invalid; + } + + if (start == null) { + return_obj.start.status = missing; + } else if (!isTimeValidHHMM(start)) { + return_obj.start.status = invalid; + } + + if (end == null) { + return_obj.end.status = missing; + } else if (!isTimeValidHHMM(end)) { + return_obj.end.status = invalid; + } + + if (Object.keys(return_obj).some((v) => { return return_obj[v].status != ok; })) { + return fail(400, { edit_entry: return_obj }); + } + + let current = locals.user.get_entry(id); + + if (!current) { + return fail(404, { new_entry: return_obj }); + } + + if (current.id == id && current.date == date && current.start == start && current.end == end && current.comment == comment) { + return { success: false, edit_entry: { return_obj } } + } + + let res = false; + + try { + res = locals.user.update_entry(id, date, start, end, comment); + } catch (e) { + if (!(e instanceof SQLiteError)) { + throw e; + } + } + + if (!res) { + return fail(500, { }) + } + + redirect(303, '/'); + return { success: true }; + }, +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 20787c6..562bff2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,65 +1,100 @@
-
+
validateForm(e, new_state)} use:enhance>
+
validateForm(e, edit_state)} use:enhance>
@@ -436,117 +120,73 @@ - + + + + + +{#each data.records as entry} + {#if editing?.id != entry.id } + {@const weekday = parseDate(entry.date)?.getDay()} + + {#if weekday != null } + + {:else} + + {/if} + + + + - - - - - - - - - - - - - - -{#each entries as entry} - - - {#each entry as i} - - {/each} + {:else} + + + +
{ + event.preventDefault(); + setEditing(null); + }} + > + + +
+ {/if} {/each} -{#if entries === undefined || entries.length === 0} +{#if data.records === undefined || data.records.length === 0} - + {/if} @@ -556,6 +196,11 @@ diff --git a/src/routes/record_input_row.svelte b/src/routes/record_input_row.svelte new file mode 100644 index 0000000..8788c0a --- /dev/null +++ b/src/routes/record_input_row.svelte @@ -0,0 +1,623 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/svelte.config.js b/svelte.config.js index 1295460..6bcb485 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ @@ -15,4 +15,4 @@ const config = { } }; -export default config; +export default config
Stundenliste
Ende Dauer AnmerkungActionsActions
{entry.date}{WEEKDAYS[weekday]}{entry.start}{entry.end}{calculateDuration(entry.start, entry.end)}{entry.comment ?? ""} - { - dateInput.select(); - dateValid = true; - } - } - onfocusout={ - (_) => { - dateValid = validateDate(dateInput); - if (dateValid) { - inWeekDay = WEEKDAYS[parseDate(dateInput.value)!.getDay()]; - } - } - } - required> +
{ + event.preventDefault(); + setEditing(entry); + }} + > + +
- {inWeekDay} - - { - startInput.select(); - startValid = true; - } - } - onfocusout={ - (_) => { - startValid = validateTime(startInput); - inDuration = calculateDuration(startInput.value, endInput.value); - } - } - required> - - { - endInput.select(); - endValid = true; - } - } - onfocusout={ - (_) => { - endValid = validateTime(endInput); - inDuration = calculateDuration(startInput.value, endInput.value); - } - } - required> - - {inDuration} - - - - -
{i}
No elementsNo records
+ { + dateInput.select(); + states.date.valid = true; + } + } + onfocusout={ + (_) => { + states.date.valid = validateDate(dateInput); + states.date.value = dateInput.value; + } + } + disabled={!enabled} + required> + + {inWeekDay} + + { + startInput.select(); + states.start.valid = true; + } + } + onfocusout={ + (_) => { + states.start.valid = validateTime(startInput); + states.start.value = startInput.value; + updateDuration(); + } + } + disabled={!enabled} + required> + + { + endInput.select(); + states.end.valid = true; + } + } + onfocusout={ + (_) => { + states.end.valid = validateTime(endInput); + states.end.value = endInput.value; + updateDuration(); + } + } + disabled={!enabled} + required> + + {inDuration} + + + + {@render children?.()} +