Plan Checker, Wochenbericht-Fokus-Modus und Wandtafel-Dashboard-Feinschliff

Plan Checker (neues Studenten-Werkzeug, Seitenleiste + Admin → Plan-Checker-
Merkmale): lädt einen Bauplan als PDF hoch und lässt ChatGPT oder Claude ihn
gegen eine gepflegte Merkmalsliste prüfen. PDF-Rendering (pdf.js +
@napi-rs/canvas) berücksichtigt jetzt Ebenen (Optional Content Groups) und
WASM-Decoder korrekt, sendet eine Übersicht plus mehrere hochaufgelöste
Kachel-Ausschnitte statt eines einzelnen, zu groben Bilds, und beide Anbieter
bekommen dasselbe Bildmaterial. Jedes Prüfergebnis kann auf eines der
gesendeten Bilder als Beleg verweisen (bildIndex) — als Screenshot im
Ergebnis anklickbar, öffnet vergrößert in einem Modal statt eines von Chrome
blockierten target=_blank-Links auf eine data:-URL. KI-Modellname ist jetzt
pro Konto überschreibbar (Einstellungen → Mein Konto) für Drittanbieter mit
abweichendem Modell-Katalog.

Wochenbericht: dichte Kartenansicht bekommt einen Fokus-Modus — nur der
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
aufklappbaren Abschnitten statt dauerhaft nebeneinander zu stehen.

Projektbereich: Terminplan-Auslastung rechnet jetzt exakt wie der
Auslastungs-Tab/die Wandtafel (statt einer unabhängigen, tageweisen
Schätzung); die LPH-Fortschrittsliste im Dashboard zeigt nur noch die
beauftragten Leistungsphasen.

Wandtafel: Dashboard-Kopf mit Kennzahlen-Reihe und Panel-Reihe neu
strukturiert, in den Blautönen des Firmenlogos; dieselbe Palette wurde
anschließend auf den gesamten App-weiten Dark Mode übernommen.

Dateibereinigung: nicht mehr benötigte Test-Uploads/Backups entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-05 14:01:41 +02:00
1 parent db63a29365
commit 6ba93b2a92
308 files changed
+64441 -743

No files matched your search

+469
View File
@@ -0,0 +1,469 @@
'use strict'
const { Writable } = require('node:stream')
const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
let DatabaseSync
const VERSION = 3
// 2gb
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
/**
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
* @implements {CacheStore}
*
* @typedef {{
* id: Readonly<number>,
* body?: Uint8Array
* statusCode: number
* statusMessage: string
* headers?: string
* vary?: string
* etag?: string
* cacheControlDirectives?: string
* cachedAt: number
* staleAt: number
* deleteAt: number
* }} SqliteStoreValue
*/
module.exports = class SqliteCacheStore {
#maxEntrySize = MAX_ENTRY_SIZE
#maxCount = Infinity
/**
* @type {import('node:sqlite').DatabaseSync}
*/
#db
/**
* @type {import('node:sqlite').StatementSync}
*/
#getValuesQuery
/**
* @type {import('node:sqlite').StatementSync}
*/
#updateValueQuery
/**
* @type {import('node:sqlite').StatementSync}
*/
#insertValueQuery
/**
* @type {import('node:sqlite').StatementSync}
*/
#deleteExpiredValuesQuery
/**
* @type {import('node:sqlite').StatementSync}
*/
#deleteByUrlQuery
/**
* @type {import('node:sqlite').StatementSync}
*/
#countEntriesQuery
/**
* @type {import('node:sqlite').StatementSync | null}
*/
#deleteOldValuesQuery
/**
* @param {import('../../types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts | undefined} opts
*/
constructor (opts) {
if (opts) {
if (typeof opts !== 'object') {
throw new TypeError('SqliteCacheStore options must be an object')
}
if (opts.maxEntrySize !== undefined) {
if (
typeof opts.maxEntrySize !== 'number' ||
!Number.isInteger(opts.maxEntrySize) ||
opts.maxEntrySize < 0
) {
throw new TypeError('SqliteCacheStore options.maxEntrySize must be a non-negative integer')
}
if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
}
this.#maxEntrySize = opts.maxEntrySize
}
if (opts.maxCount !== undefined) {
if (
typeof opts.maxCount !== 'number' ||
!Number.isInteger(opts.maxCount) ||
opts.maxCount < 0
) {
throw new TypeError('SqliteCacheStore options.maxCount must be a non-negative integer')
}
this.#maxCount = opts.maxCount
}
}
if (!DatabaseSync) {
DatabaseSync = require('node:sqlite').DatabaseSync
}
this.#db = new DatabaseSync(opts?.location ?? ':memory:')
this.#db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = memory;
PRAGMA optimize;
CREATE TABLE IF NOT EXISTS cacheInterceptorV${VERSION} (
-- Data specific to us
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
method TEXT NOT NULL,
-- Data returned to the interceptor
body BUF NULL,
deleteAt INTEGER NOT NULL,
statusCode INTEGER NOT NULL,
statusMessage TEXT NOT NULL,
headers TEXT NULL,
cacheControlDirectives TEXT NULL,
etag TEXT NULL,
vary TEXT NULL,
cachedAt INTEGER NOT NULL,
staleAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, deleteAt);
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteByUrlQuery ON cacheInterceptorV${VERSION}(deleteAt);
`)
this.#getValuesQuery = this.#db.prepare(`
SELECT
id,
body,
deleteAt,
statusCode,
statusMessage,
headers,
etag,
cacheControlDirectives,
vary,
cachedAt,
staleAt
FROM cacheInterceptorV${VERSION}
WHERE
url = ?
AND method = ?
ORDER BY
deleteAt ASC
`)
this.#updateValueQuery = this.#db.prepare(`
UPDATE cacheInterceptorV${VERSION} SET
body = ?,
deleteAt = ?,
statusCode = ?,
statusMessage = ?,
headers = ?,
etag = ?,
cacheControlDirectives = ?,
vary = ?,
cachedAt = ?,
staleAt = ?
WHERE
id = ?
`)
this.#insertValueQuery = this.#db.prepare(`
INSERT INTO cacheInterceptorV${VERSION} (
url,
method,
body,
deleteAt,
statusCode,
statusMessage,
headers,
etag,
cacheControlDirectives,
vary,
cachedAt,
staleAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
this.#deleteByUrlQuery = this.#db.prepare(
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`
)
this.#countEntriesQuery = this.#db.prepare(
`SELECT COUNT(*) AS total FROM cacheInterceptorV${VERSION}`
)
this.#deleteExpiredValuesQuery = this.#db.prepare(
`DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?`
)
this.#deleteOldValuesQuery = this.#maxCount === Infinity
? null
: this.#db.prepare(`
DELETE FROM cacheInterceptorV${VERSION}
WHERE id IN (
SELECT
id
FROM cacheInterceptorV${VERSION}
ORDER BY cachedAt ASC
LIMIT ?
)
`)
}
close () {
this.#db.close()
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @returns {(import('../../types/cache-interceptor.d.ts').default.GetResult & { body?: Buffer }) | undefined}
*/
get (key) {
assertCacheKey(key)
const value = this.#findValue(key)
return value
? {
body: value.body ? Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength) : undefined,
statusCode: value.statusCode,
statusMessage: value.statusMessage,
headers: value.headers ? JSON.parse(value.headers) : undefined,
etag: value.etag ? value.etag : undefined,
vary: value.vary ? JSON.parse(value.vary) : undefined,
cacheControlDirectives: value.cacheControlDirectives
? JSON.parse(value.cacheControlDirectives)
: undefined,
cachedAt: value.cachedAt,
staleAt: value.staleAt,
deleteAt: value.deleteAt
}
: undefined
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @param {import('../../types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>}} value
*/
set (key, value) {
assertCacheKey(key)
const url = this.#makeValueUrl(key)
const body = Array.isArray(value.body) ? Buffer.concat(value.body) : value.body
const size = body?.byteLength
if (size && size > this.#maxEntrySize) {
return
}
const existingValue = this.#findValue(key, true)
if (existingValue) {
// Updating an existing response, let's overwrite it
this.#updateValueQuery.run(
body,
value.deleteAt,
value.statusCode,
value.statusMessage,
value.headers ? JSON.stringify(value.headers) : null,
value.etag ? value.etag : null,
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
value.vary ? JSON.stringify(value.vary) : null,
value.cachedAt,
value.staleAt,
existingValue.id
)
} else {
// New response, let's insert it
this.#insertValueQuery.run(
url,
key.method,
body,
value.deleteAt,
value.statusCode,
value.statusMessage,
value.headers ? JSON.stringify(value.headers) : null,
value.etag ? value.etag : null,
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
value.vary ? JSON.stringify(value.vary) : null,
value.cachedAt,
value.staleAt
)
this.#prune()
}
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} value
* @returns {Writable | undefined}
*/
createWriteStream (key, value) {
assertCacheKey(key)
assertCacheValue(value)
let size = 0
/**
* @type {Buffer[] | null}
*/
const body = []
const store = this
return new Writable({
decodeStrings: true,
write (chunk, encoding, callback) {
size += chunk.byteLength
if (size <= store.#maxEntrySize) {
body.push(chunk)
} else {
this.destroy()
}
callback()
},
final (callback) {
store.set(key, { ...value, body })
callback()
}
})
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
*/
delete (key) {
if (typeof key !== 'object') {
throw new TypeError(`expected key to be object, got ${typeof key}`)
}
this.#deleteByUrlQuery.run(this.#makeValueUrl(key))
}
#prune () {
if (Number.isFinite(this.#maxCount) && this.size <= this.#maxCount) {
return 0
}
{
const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes
if (removed) {
return removed
}
}
{
const removed = this.#deleteOldValuesQuery?.run(Math.max(Math.floor(this.#maxCount * 0.1), 1)).changes
if (removed) {
return removed
}
}
return 0
}
/**
* Counts the number of rows in the cache
* @returns {Number}
*/
get size () {
const { total } = this.#countEntriesQuery.get()
return total
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @returns {string}
*/
#makeValueUrl (key) {
return `${key.origin}/${key.path}`
}
/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
* @param {boolean} [canBeExpired=false]
* @returns {SqliteStoreValue | undefined}
*/
#findValue (key, canBeExpired = false) {
const url = this.#makeValueUrl(key)
const { headers, method } = key
/**
* @type {SqliteStoreValue[]}
*/
const values = this.#getValuesQuery.all(url, method)
if (values.length === 0) {
return undefined
}
const now = Date.now()
for (const value of values) {
if (now >= value.deleteAt && !canBeExpired) {
continue
}
let matches = true
if (value.vary) {
const vary = JSON.parse(value.vary)
for (const header in vary) {
if (!headerValueEquals(headers[header], vary[header])) {
matches = false
break
}
}
}
if (matches) {
return value
}
}
return undefined
}
}
/**
* @param {string|string[]|null|undefined} lhs
* @param {string|string[]|null|undefined} rhs
* @returns {boolean}
*/
function headerValueEquals (lhs, rhs) {
if (lhs == null && rhs == null) {
return true
}
if ((lhs == null && rhs != null) ||
(lhs != null && rhs == null)) {
return false
}
if (Array.isArray(lhs) && Array.isArray(rhs)) {
if (lhs.length !== rhs.length) {
return false
}
for (let i = 0; i < lhs.length; i++) {
if (lhs[i] !== rhs[i]) {
return false
}
}
return true
}
return lhs === rhs
}