implemented update, moved input to component

This commit is contained in:
Patrick 2025-01-03 01:04:30 +01:00
parent 828895e773
commit 3885082d2f
14 changed files with 1383 additions and 529 deletions

3
.gitignore vendored
View File

@ -21,3 +21,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Databases
*.sqlite

BIN
bun.lockb

Binary file not shown.

10
docs/UserDatabase.uml Normal file
View File

@ -0,0 +1,10 @@
@startuml
entity "users" {
id: primary
--
name: text
database_location: text
}
@enduml

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

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

View File

@ -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"
}

38
src/hooks.server.ts Normal file
View File

@ -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);
}

20
src/lib/db_types.ts Normal file
View File

@ -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;
}

233
src/lib/server/database.ts Normal file
View File

@ -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;
}
}

89
src/lib/util.ts Normal file
View File

@ -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) == ':';
}

172
src/routes/+page.server.ts Normal file
View File

@ -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 };
},
}

View File

@ -1,65 +1,100 @@
<script lang="ts">
let { data } = $props();
import { untrack } from "svelte";
import { enhance } from "$app/forms";
import { page } from "$app/state"
import type { RecordEntry } from "$lib/db_types"
import { toInt, parseDate, calculateDuration } from "$lib/util";
import type { RowState } from "./record_input_row.svelte"
import RecordInputRow from "./record_input_row.svelte"
let { data, form } : { data: { records: RecordEntry[] }, form: any } = $props();
const WEEKDAYS: string[] = [ "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" ]
const HEADERS: string[] = [ "Datum", "Wochentag", "Beginn", "Ende", "Dauer", "Anmerkung" ];
const TODAY: Date = new Date();
const CENTURY_PREF: number = Math.floor(TODAY.getFullYear() / 100);
const CENTURY_YEAR: number = CENTURY_PREF * 100;
const status_ok = "ok";
const status_missing = "missing";
const status_invalid = "invalid";
let dateInput: HTMLInputElement;
let startInput: HTMLInputElement;
let endInput: HTMLInputElement;
let dateValid: boolean = $state(true);
let startValid: boolean = $state(true);
let endValid: boolean = $state(true);
let new_state = $state() as RowState;
let inWeekDay: string = $state("");
let inDuration: string = $state("");
let editing: RecordEntry | null = $state(null);
let edit_state = $state() as RowState;
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;
setNewState();
setEditing();
$effect(() => {
if (form) {
untrack(() => setNewState());
}
})
$effect(() => {
if (form) {
untrack(() => setEditing(null));
}
})
function setNewState() {
new_state = {
date: {
valid: form?.new_entry?.date?.value !== "" || form?.new_entry?.date?.valid,
value: form?.new_entry?.date?.value ?? "",
},
start: {
valid: form?.new_entry?.start?.value !== "" || form?.new_entry?.start?.valid,
value: form?.new_entry?.start?.value ?? "",
},
end: {
valid: form?.new_entry?.end?.value !== "" || form?.new_entry?.end?.valid,
value: form?.new_entry?.end?.value ?? "",
},
comment: {
value: form?.new_entry?.date?.comment ?? "",
},
}
}
function setEditing(entry: RecordEntry | null = null) {
if (entry) {
editing = entry;
} else if (form?.edit_entry) {
editing = form.edit_entry
} else if (page.url.searchParams.get("edit")) {
let id = toInt(page.url.searchParams.get("edit")!)
let entry = data.records.find((entry) => { return entry.id == id; })
editing = entry ?? null;
} else {
editing = null;
}
return value;
edit_state = {
date: {
valid: editing?.id != form?.edit_entry?.id || !(form?.edit_entry?.date?.status && form.edit_entry.date.status != status_ok),
value: editing?.date ?? ""
},
start: {
valid: editing?.id != form?.edit_entry?.id || !(form?.edit_entry?.start?.status && form.edit_entry.start.status != status_ok),
value: editing?.start ?? ""
},
end: {
valid: editing?.id != form?.edit_entry?.id || !(form?.edit_entry?.end?.status && form.edit_entry.end.status != status_ok),
value: editing?.end ?? ""
},
comment: {
value: editing?.comment ?? ""
},
}
}
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;
}
function validateForm(event: Event): boolean {
let valid = dateValid && dateInput.value.length !== 0
&& startValid && startInput.value.length !== 0
&& endValid && endInput.value.length !== 0;
function validateForm(event: Event, state: RowState): boolean {
let valid = state.date.valid && state.date.value.length !== 0
&& state.start.valid && state.start.value.length !== 0
&& state.end.valid && state.end.value.length !== 0;
if (!valid) {
event.preventDefault();
@ -67,363 +102,12 @@
return valid;
}
function validateDate(element: HTMLInputElement) {
/*
supports:
D.M
DDMM
D.MM
DD.M
DD.MM
DDMMYY
D.M.YY
D.M.YYYY
DD.MM.YY
DDMMYYYY
DD.MM.YYYY
*/
switch (element.value.length) {
case 0: return true;
case 3: {
/*
* D.M
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.charAt(2));
if (isNaN(day) || isNaN(month) || element.value.charAt(1) !== '.') {
return false;
}
if (day > 0 && month > 0) {
element.value = "0" + day + ".0" + month + "." + TODAY.getFullYear();
return true;
}
} break;
case 4: if (
(() =>{
/*
* DDMM
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(2, 4));
if (isNaN(day) || isNaN(month)) {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(2, 4) + "." + TODAY.getFullYear();
return true;
}
return false;
})() === true
|| (() => {
/*
* DD.M
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.charAt(3));
if (isNaN(day) || isNaN(month) || element.value.charAt(2) !== '.') {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate())) {
element.value = element.value.slice(0, 2) + ".0" + month + "." + TODAY.getFullYear();
return true;
}
return false;
})() === true
|| (() => {
/*
* D.MM
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.slice(2, 4));
if (isNaN(day) || isNaN(month) || element.value.charAt(1) !== '.') {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate())) {
element.value = "0" + day + "." + element.value.slice(2, 4) + "." + TODAY.getFullYear();
return true;
}
return false;
})() === true) {
return true;
} break;
case 5: {
/*
* DD.MM
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(3, 5));
if (isNaN(day) || isNaN(month) || element.value.charAt(2) !== '.') {
return false;
}
if (!((month < 1 || month > 12)
|| (day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate()))) {
element.value = element.value + "." + TODAY.getFullYear();
return true;
}
} break;
case 6: if ((() => {
/*
* DDMMYY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(2, 4));
let year = toInt(element.value.slice(4, 6));
if (isNaN(day) || isNaN(month) || isNaN(year)) {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(2, 4) + "." + CENTURY_PREF + element.value.slice(4, 6);
return true;
}
return false;
})() === true
|| (() => {
/*
* D.M.YY
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.charAt(2));
let year = toInt(element.value.slice(4, 6));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(1) !== '.' || element.value.charAt(3) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = "0" + day + ".0" + month + "." + CENTURY_PREF + element.value.slice(4, 6);
return true;
}
return false;
})() === true) {
return true;
} break;
case 8: if (
(() => {
/*
* D.M.YYYY
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.charAt(2));
let year = toInt(element.value.slice(4, 8));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(1) !== '.' || element.value.charAt(3) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = "0" + day + ".0" + month + "." + element.value.slice(4, 8);
return true;
}
return false;
})() === true
|| (() => {
/*
* DD.MM.YY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(3, 5));
let year = toInt(element.value.slice(6, 8));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(2) !== '.' || element.value.charAt(5) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(3, 5) + "." + CENTURY_PREF + element.value.slice(6, 8);
return true;
}
return false;
})() === true
|| (() => {
/*
* DDMMYYYY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(2, 4));
let year = toInt(element.value.slice(4, 8));
if (isNaN(day) || isNaN(month) || isNaN(year)) {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(2, 4) + "." + element.value.slice(4, 8);
return true;
}
return false;
})() === true) {
return true;
} break;
case 10: {
/*
* DD.MM.YYYY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(3, 5));
let year = toInt(element.value.slice(6, 10));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(2) !== '.' || element.value.charAt(5) !== '.') {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
return true;
}
} break;
}
return false;
}
function validateTime(element: HTMLInputElement): boolean {
/*
supports:
H:MM
HHMM
HH:MM
*/
switch(element.value.length) {
case 0: return true;
case 4: if (
(() => {
let h = toInt(element.value.slice(0, 2));
let m = toInt(element.value.slice(2, 4));
if (isNaN(h) || isNaN(m)) {
return false;
}
if (0 <= h && h <= 24 && 0 <= m && m <= 59) {
element.value = element.value.slice(0, 2) + ":" + element.value.slice(2, 4);
return true;
}
return false;
})() === true
|| (() => {
let h = toInt(element.value.charAt(0));
let m = toInt(element.value.slice(2, 4));
if (isNaN(h) || isNaN(m) || element.value.charAt(1) !== ':') {
return false;
}
if (0 <= m && m <= 59) {
element.value = "0" + element.value;
return true;
}
return false;
})() === true) {
return true;
}
case 5: {
let h = toInt(element.value.slice(0, 2));
let m = toInt(element.value.slice(3, 5));
if (isNaN(h) || isNaN(m) || element.value.charAt(2) !== ':') {
return false;
}
if (0 <= h && h <= 24 && 0 <= m && m <= 59) {
return true;
}
} break;
}
return false;
}
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);
}
let entries: number[][] = [];
for (let i = 0; i < 50; ++i) {
entries.push([i, i, i, i, i, i, i*i]);
}
</script>
<div class="wrap">
<form id="form_new_entry" method="POST" action="?/new_entry" onsubmit={validateForm}></form>
<form id="form_new_entry" method="POST" action="?/new_entry" onsubmit={(e) => validateForm(e, new_state)} use:enhance></form>
<form id="form_edit_entry" method="POST" action="?/edit_entry" onsubmit={(e) => validateForm(e, edit_state)} use:enhance></form>
<table class="list">
<caption>Stundenliste</caption>
@ -436,117 +120,73 @@
<th style:width="12ch">Ende</th>
<th style:width="12ch">Dauer</th>
<th>Anmerkung</th>
<th style:width="fit-content">Actions</th>
<th style:width="12ch">Actions</th>
</tr>
</thead>
<tbody>
<RecordInputRow targetForm="form_new_entry" bind:states={new_state} enabled={editing == null}>
<button
type="submit"
form="form_new_entry"
disabled={editing != null}>
+
</button>
</RecordInputRow>
{#each data.records as entry}
{#if editing?.id != entry.id }
{@const weekday = parseDate(entry.date)?.getDay()}
<tr>
<td>{entry.date}</td>
{#if weekday != null }
<td>{WEEKDAYS[weekday]}</td>
{:else}
<td></td>
{/if}
<td>{entry.start}</td>
<td>{entry.end}</td>
<td>{calculateDuration(entry.start, entry.end)}</td>
<td>{entry.comment ?? ""}</td>
<td>
<input
bind:this={dateInput}
class:form-invalid={!dateValid}
name="date"
type="text"
form="form_new_entry"
onfocusin={
(_) => {
dateInput.select();
dateValid = true;
}
}
onfocusout={
(_) => {
dateValid = validateDate(dateInput);
if (dateValid) {
inWeekDay = WEEKDAYS[parseDate(dateInput.value)!.getDay()];
}
}
}
required>
<form
id="form_edit"
method="GET"
onsubmit={(event) => {
event.preventDefault();
setEditing(entry);
}}
>
<button type="submit" name="edit" value={entry.id}>Edit</button>
</form>
</td>
<td>
{inWeekDay}
</td>
<td>
<input
bind:this={startInput}
class:form-invalid={!startValid}
name="starttime"
type="text"
form="form_new_entry"
onfocusin={
(_) => {
startInput.select();
startValid = true;
}
}
onfocusout={
(_) => {
startValid = validateTime(startInput);
inDuration = calculateDuration(startInput.value, endInput.value);
}
}
required>
</td>
<td>
<input
bind:this={endInput}
class:form-invalid={!endValid}
name="endtime"
type="text"
form="form_new_entry"
onfocusin={
(_) => {
endInput.select();
endValid = true;
}
}
onfocusout={
(_) => {
endValid = validateTime(endInput);
inDuration = calculateDuration(startInput.value, endInput.value);
}
}
required>
</td>
<td>
{inDuration}
</td>
<td>
<input
name="comment"
type="text"
form="form_new_entry">
</td>
<td>
<button
type="submit"
form="form_new_entry">
+
</button>
</td>
</tr>
{#each entries as entry}
<!--<tr><td>{entry}</td></tr>-->
<tr>
{#each entry as i}
<td>{i}</td>
{/each}
</tr>
{:else}
<RecordInputRow targetForm="form_edit_entry" bind:states={edit_state} enabled={true} >
<input type="hidden" form="form_edit_entry" name="id" value={entry.id} />
<button
type="submit"
form="form_edit_entry">
Save
</button>
<form
id="form_edit_cancel"
method="GET"
onsubmit={(event) => {
event.preventDefault();
setEditing(null);
}}
>
<button type="submit">Cancel</button>
</form>
</RecordInputRow>
{/if}
{/each}
<tr></tr>
</tbody>
{#if entries === undefined || entries.length === 0}
{#if data.records === undefined || data.records.length === 0}
<tfoot>
<tr>
<td class="td-no-elements" colspan="999">No elements</td>
<td class="td-no-elements" colspan="999">No records</td>
</tr>
</tfoot>
{/if}
@ -556,6 +196,11 @@
<style>
form {
width: fit-content;
border: none;
}
table {
width: 80%;
margin: auto;
@ -569,31 +214,20 @@ table caption {
font-weight: bold;
}
tbody * {
border: 1px solid;
}
tbody tr:nth-child(even) {
tbody tr:nth-child(odd) {
background: gray;
}
tbody tr:nth-child(odd) {
tbody tr:nth-child(even) {
background: lightgray;
}
td input {
box-sizing: border-box;
width: 100%;
border: none;
tr td:last-of-type {
display: inline-flex;
}
.td-no-elements {
text-align: center;
}
.form-invalid {
background: #FF4444;
}
</style>

View File

@ -0,0 +1,623 @@
<script module lang="ts">
export interface RowState {
date: { valid: boolean, value: string},
start: { valid: boolean, value: string},
end: { valid: boolean, value: string},
comment: { value: string }
}
</script>
<script lang="ts">
import type { Snippet } from "svelte";
import { toInt, parseDate, calculateDuration } from "$lib/util";
interface Props {
targetForm: string,
enabled: boolean,
states: RowState,
children: Snippet
}
let { targetForm, enabled, states = $bindable(), children }: Props = $props();
const WEEKDAYS: string[] = [ "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag" ]
const TODAY: Date = new Date();
const CENTURY_PREF: number = Math.floor(TODAY.getFullYear() / 100);
const CENTURY_YEAR: number = CENTURY_PREF * 100;
let dateInput: HTMLInputElement;
let startInput: HTMLInputElement;
let endInput: HTMLInputElement;
states = {
...states,
...(states?.date ? states.date : {}),
...(states?.start ? states.start : {}),
...(states?.end ? states.end : {}),
...(states?.comment ? states.comment : {}),
date: {
...states.date,
...(states.date?.valid == null && { valid: true }),
...(states.date?.value == null && { value: "" }),
},
start: {
...states.start,
...(states.start?.valid == null && { valid: true }),
...(states.start?.value == null && { value: "" }),
},
end: {
...states.end,
...(states.end?.valid == null && { valid: true }),
...(states.end?.value == null && { value: "" }),
},
comment: {
...states.comment,
...(states.comment?.value == null && { value: "" }),
},
};
let inWeekDay: string = $derived.by(() => {
let date = null;
if (states?.date?.valid && states?.date?.value != null) {
date = parseDate(states.date.value);
}
return date ? WEEKDAYS[date.getDay()] : "";
})
let inDuration: string = $state("");
updateDuration();
function updateDuration() {
if ((states?.start?.valid && states?.end?.valid)
&& (states?.start?.value != null && states?.end?.value != null)) {
inDuration = calculateDuration(states.start.value, states.end.value)
}
}
function validateDate(element: HTMLInputElement) {
/*
todo: trailing . after month
supports:
D.M
DDMM
D.MM
DD.M
DD.MM
DDMMYY
D.M.YY
DD.M.YY
D.MM.YY
D.M.YYYY
DD.MM.YY
DDMMYYYY
DD.M.YYYY
D.MM.YYYY
DD.MM.YYYY
*/
switch (element.value.length) {
case 0: return true;
case 3: {
/*
* D.M
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.charAt(2));
if (isNaN(day) || isNaN(month) || element.value.charAt(1) !== '.') {
return false;
}
if (day > 0 && month > 0) {
element.value = "0" + day + ".0" + month + "." + TODAY.getFullYear();
return true;
}
} break;
case 4: if (
(() =>{
/*
* DDMM
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(2, 4));
if (isNaN(day) || isNaN(month)) {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(2, 4) + "." + TODAY.getFullYear();
return true;
}
return false;
})() === true
|| (() => {
/*
* DD.M
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.charAt(3));
if (isNaN(day) || isNaN(month) || element.value.charAt(2) !== '.') {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate())) {
element.value = element.value.slice(0, 2) + ".0" + month + "." + TODAY.getFullYear();
return true;
}
return false;
})() === true
|| (() => {
/*
* D.MM
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.slice(2, 4));
if (isNaN(day) || isNaN(month) || element.value.charAt(1) !== '.') {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate())) {
element.value = "0" + day + "." + element.value.slice(2, 4) + "." + TODAY.getFullYear();
return true;
}
return false;
})() === true) {
return true;
} break;
case 5: {
/*
* DD.MM
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(3, 5));
if (isNaN(day) || isNaN(month) || element.value.charAt(2) !== '.') {
return false;
}
if (!((month < 1 || month > 12)
|| (day < 1 || day > new Date(TODAY.getFullYear(), month, 0).getDate()))) {
element.value = element.value + "." + TODAY.getFullYear();
return true;
}
} break;
case 6: if ((() => {
/*
* DDMMYY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(2, 4));
let year = toInt(element.value.slice(4, 6));
if (isNaN(day) || isNaN(month) || isNaN(year)) {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(2, 4) + "." + year;
return true;
}
return false;
})() === true
|| (() => {
/*
* D.M.YY
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.charAt(2));
let year = toInt(element.value.slice(4, 6));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(1) !== '.' || element.value.charAt(3) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = "0" + day + ".0" + month + "." + year;
return true;
}
return false;
})() === true) {
return true;
} break;
case 7: if ((() => {
/*
* D.MM.YY
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.slice(2, 4));
let year = toInt(element.value.slice(5, 7));
if (isNaN(day) || isNaN(month) || element.value.charAt(1) !== '.' || element.value.charAt(4) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = "0" + element.value.slice(0, 5) + year;
return true;
}
return false;
})() === true
|| (() => {
/*
* D.MM.YY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.charAt(3));
let year = toInt(element.value.slice(5, 7));
if (isNaN(day) || isNaN(month) || element.value.charAt(2) !== '.' || element.value.charAt(4) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 3) + "0" + month + "." + year;
return true;
}
return false;
})() === true) {
return true;
} break;
case 8: if (
(() => {
/*
* D.M.YYYY
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.charAt(2));
let year = toInt(element.value.slice(4, 8));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(1) !== '.' || element.value.charAt(3) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = "0" + day + ".0" + month + "." + element.value.slice(4, 8);
return true;
}
return false;
})() === true
|| (() => {
/*
* DD.MM.YY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(3, 5));
let year = toInt(element.value.slice(6, 8));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(2) !== '.' || element.value.charAt(5) !== '.') {
return false;
}
year += CENTURY_YEAR;
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(3, 5) + "." + CENTURY_PREF + element.value.slice(6, 8);
return true;
}
return false;
})() === true
|| (() => {
/*
* DDMMYYYY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(2, 4));
let year = toInt(element.value.slice(4, 8));
if (isNaN(day) || isNaN(month) || isNaN(year)) {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 2) + "." + element.value.slice(2, 4) + "." + element.value.slice(4, 8);
return true;
}
return false;
})() === true) {
return true;
} break;
case 9: if ((() => {
/*
* D.MM.YYYY
*/
let day = toInt(element.value.charAt(0));
let month = toInt(element.value.slice(2, 4));
let year = toInt(element.value.slice(5, 9));
if (isNaN(day) || isNaN(month) || element.value.charAt(1) !== '.' || element.value.charAt(4) !== '.') {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = "0" + element.value;
return true;
}
return false;
})() === true
|| (() => {
/*
* D.MM.YYYY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.charAt(3));
let year = toInt(element.value.slice(5, 9));
if (isNaN(day) || isNaN(month) || element.value.charAt(2) !== '.' || element.value.charAt(4) !== '.') {
return false;
}
if (!(day < 1 || day > new Date(year, month, 0).getDate())) {
element.value = element.value.slice(0, 3) + "0" + month + element.value.slice(4, 9);
return true;
}
return false;
})() === true) {
return true;
} break;
case 10: {
/*
* DD.MM.YYYY
*/
let day = toInt(element.value.slice(0, 2));
let month = toInt(element.value.slice(3, 5));
let year = toInt(element.value.slice(6, 10));
if (isNaN(day) || isNaN(month) || isNaN(year) || element.value.charAt(2) !== '.' || element.value.charAt(5) !== '.') {
return false;
}
if (!(month < 1 || month > 12
|| day < 1 || day > new Date(year, month, 0).getDate())) {
return true;
}
} break;
}
return false;
}
function validateTime(element: HTMLInputElement): boolean {
/*
supports:
HH
H:MM
HHMM
HH:MM
*/
switch(element.value.length) {
case 0: return true;
case 2: {
let h = toInt(element.value);
if (!isNaN(h) && 0 < h && h <= 24) {
element.value = element.value + ":00";
return true;
}
} break;
case 4: if (
(() => {
let h = toInt(element.value.slice(0, 2));
let m = toInt(element.value.slice(2, 4));
if (isNaN(h) || isNaN(m)) {
return false;
}
if (0 <= h && h <= 24 && 0 <= m && m <= 59) {
element.value = element.value.slice(0, 2) + ":" + element.value.slice(2, 4);
return true;
}
return false;
})() === true
|| (() => {
let h = toInt(element.value.charAt(0));
let m = toInt(element.value.slice(2, 4));
if (isNaN(h) || isNaN(m) || element.value.charAt(1) !== ':') {
return false;
}
if (0 <= m && m <= 59) {
element.value = "0" + element.value;
return true;
}
return false;
})() === true) {
return true;
}
case 5: {
let h = toInt(element.value.slice(0, 2));
let m = toInt(element.value.slice(3, 5));
if (isNaN(h) || isNaN(m) || element.value.charAt(2) !== ':') {
return false;
}
if (0 <= h && h <= 24 && 0 <= m && m <= 59) {
return true;
}
} break;
}
return false;
}
</script>
<tr>
<td>
<input
bind:this={dateInput}
bind:value={states.date.value}
class:form-invalid={!states.date.valid}
name="date"
type="text"
form={targetForm}
onfocusin={
(_) => {
dateInput.select();
states.date.valid = true;
}
}
onfocusout={
(_) => {
states.date.valid = validateDate(dateInput);
states.date.value = dateInput.value;
}
}
disabled={!enabled}
required>
</td>
<td>
{inWeekDay}
</td>
<td>
<input
bind:this={startInput}
bind:value={states.start.value}
class:form-invalid={!states.start.valid}
name="start"
type="text"
form={targetForm}
onfocusin={
(_) => {
startInput.select();
states.start.valid = true;
}
}
onfocusout={
(_) => {
states.start.valid = validateTime(startInput);
states.start.value = startInput.value;
updateDuration();
}
}
disabled={!enabled}
required>
</td>
<td>
<input
bind:this={endInput}
bind:value={states.end.value}
class:form-invalid={!states.end.valid}
name="end"
type="text"
form={targetForm}
onfocusin={
(_) => {
endInput.select();
states.end.valid = true;
}
}
onfocusout={
(_) => {
states.end.valid = validateTime(endInput);
states.end.value = endInput.value;
updateDuration();
}
}
disabled={!enabled}
required>
</td>
<td>
{inDuration}
</td>
<td>
<input
name="comment"
type="text"
form={targetForm}
value={states.comment.value}
disabled={!enabled}>
</td>
<td>
{@render children?.()}
</td>
</tr>
<style>
/* * {
border: 1px solid;
}*/
td input {
box-sizing: border-box;
width: 100%;
vertical-align: middle;
border: none;
}
tr td:last-of-type {
display: inline-flex;
}
.form-invalid {
background: #FF4444;
}
</style>

View File

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