refactor: remove DaisyUI and implement custom theme system
- Remove DaisyUI dependency from package.json - Implement custom CSS components (btn, badge, navbar, menu, card, footer) - Add dark/light theme toggle with localStorage persistence - Update color scheme to dark blue palette (primary: blue, secondary: cyan, accent: electric blue) - Make blog pages full-width by removing max-width constraints - Restore sidebar navigation for clqms posts with full-width layout - Fix navbar menu alignment issues with flexbox BREAKING CHANGE: DaisyUI classes replaced with custom implementations
This commit is contained in:
parent
b3a1323368
commit
ec37ccc9bb
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -11,7 +11,6 @@
|
||||
"devDependencies": {
|
||||
"@11ty/eleventy": "^3.1.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"daisyui": "^5.5.14",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
@ -1073,16 +1072,6 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.5.14",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz",
|
||||
"integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "5panda.11ty",
|
||||
"version": "1.0.0",
|
||||
"description": "5Panda Portfolio, Blog & Documentation site built with 11ty, Tailwind CSS v4 and daisyUI v5",
|
||||
"description": "5Panda Portfolio, Blog & Documentation site built with 11ty and Tailwind CSS v4",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all --parallel dev:*",
|
||||
"dev:11ty": "eleventy --serve",
|
||||
@ -15,18 +15,16 @@
|
||||
"blog",
|
||||
"docs",
|
||||
"11ty",
|
||||
"tailwindcss",
|
||||
"daisyui"
|
||||
"tailwindcss"
|
||||
],
|
||||
"author": "5Panda",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@11ty/eleventy": "^3.1.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"daisyui": "^5.5.14",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"tailwindcss": "^4.1.18"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="panda">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@ -16,7 +16,19 @@
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"> <!-- Styles -->
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link
|
||||
rel="stylesheet" href="/css/style.css">
|
||||
<!-- Theme initialization script (must be in head to prevent FOUC) -->
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
class="min-h-screen bg-base-100 text-base-content">
|
||||
@ -58,7 +70,7 @@
|
||||
</summary>
|
||||
<ul class="p-2 bg-base-100 rounded-t-none bg-base-100/95 backdrop-blur-xl border border-white/5 shadow-xl w-60 z-[100]">
|
||||
{% for post in collections.posts %}
|
||||
{% if 'clqms' not in post.data.tags %}
|
||||
{% if post.data.tags and 'clqms' not in post.data.tags %}
|
||||
<li>
|
||||
<a href="{{ post.url }}" class="{% if page.url == post.url %}text-primary bg-primary/10{% endif %} hover:text-primary hover:bg-primary/10">
|
||||
{{ post.data.title }}
|
||||
@ -74,15 +86,14 @@
|
||||
<div
|
||||
class="navbar-end">
|
||||
<!-- Theme toggle -->
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
|
||||
<input type="checkbox" class="theme-controller" value="pandaLight"/>
|
||||
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
||||
<button id="theme-toggle" class="btn btn-ghost btn-circle btn-sm theme-toggle" aria-label="Toggle theme">
|
||||
<svg class="icon-sun h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
||||
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
|
||||
</svg>
|
||||
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
||||
<svg class="icon-moon h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
||||
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/>
|
||||
</svg>
|
||||
</label>
|
||||
</button>
|
||||
<!-- GitHub link -->
|
||||
<a href="https://github.com" target="_blank" class="btn btn-ghost btn-circle btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewbox="0 0 24 24">
|
||||
@ -128,8 +139,20 @@
|
||||
</div>
|
||||
</nav>
|
||||
<aside>
|
||||
<p class="text-sm text-base-content/40">© {% year %} 5Panda. Built with 11ty, Tailwind CSS & daisyUI.</p>
|
||||
<p class="text-sm text-base-content/40">© {% year %} 5Panda. Built with 11ty & Tailwind CSS.</p>
|
||||
</aside>
|
||||
</footer>
|
||||
<!-- Theme toggle script -->
|
||||
<script>
|
||||
document.getElementById('theme-toggle').addEventListener('click', function () {
|
||||
const html = document.documentElement;
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light'
|
||||
? 'dark'
|
||||
: 'light';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -2,11 +2,10 @@
|
||||
layout: base.njk
|
||||
---
|
||||
|
||||
<div class="section-container py-12 animate-slide-up">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row gap-12">
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-12 animate-slide-up">
|
||||
<div class="flex flex-col lg:flex-row gap-12">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="lg:w-1/4">
|
||||
<aside class="lg:w-64 flex-shrink-0">
|
||||
<div class="sticky top-24 bg-base-200/50 backdrop-blur-xl border border-white/5 rounded-2xl p-6">
|
||||
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
@ -41,7 +40,7 @@ layout: base.njk
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main Content -->
|
||||
<main class="lg:w-3/4 min-w-0">
|
||||
<main class="flex-1 min-w-0">
|
||||
<article>
|
||||
<!-- Post header -->
|
||||
<header class="mb-10">
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
---
|
||||
layout: base.njk
|
||||
---
|
||||
|
||||
<article class="section-container py-12 animate-slide-up">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div>
|
||||
<!-- Post header -->
|
||||
<header class="mb-10">
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{% for tag in tags %}
|
||||
{% if tag != "post" and tag != "posts" %}
|
||||
<span class="badge badge-primary badge-outline">{{ tag }}</span>
|
||||
<span class="badge badge-primary badge-outline">{{ tag }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@ -16,32 +17,34 @@ layout: base.njk
|
||||
<div class="flex flex-wrap items-center gap-4 text-base-content/60">
|
||||
<time datetime="{{ date | dateFormat('iso') }}" class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{{ date | dateFormat('full') }}
|
||||
</time>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ content | readingTime }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Post content -->
|
||||
<div class="prose-custom max-w-none">
|
||||
{{ content | safe }}
|
||||
</div>
|
||||
|
||||
<!-- Post footer -->
|
||||
<footer class="mt-12 pt-8 border-t border-white/10">
|
||||
<a href="/blog/" class="btn btn-outline btn-primary gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Back to Blog
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
563
src/blog/clqms-roast-Opus.md
Normal file
563
src/blog/clqms-roast-Opus.md
Normal file
@ -0,0 +1,563 @@
|
||||
---
|
||||
title: "Database Design Roast: Claude Opus"
|
||||
description: "A professional roast of the CLQMS database schema."
|
||||
date: 2025-12-12
|
||||
order: 6
|
||||
tags:
|
||||
- posts
|
||||
- clqms
|
||||
layout: clqms-post.njk
|
||||
---
|
||||
|
||||
# 🔥 CLQMS Database Schema: A Professional Roast 🔥
|
||||
|
||||
|
||||
> *"I've seen spaghetti code before, but this is spaghetti architecture."*
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After reviewing the CLQMS (Clinical Laboratory Quality Management System) database schema, I regret to inform you that this is not a database design — it's a **crime scene**. Someone read *"Design Patterns for Dummies"* and decided to implement ALL of them. Simultaneously. Incorrectly.
|
||||
|
||||
Let me walk you through this masterpiece of over-engineering.
|
||||
|
||||
---
|
||||
|
||||
## 1. 🗑️ The ValueSet Anti-Pattern: Where Enums Go to Die
|
||||
|
||||
### What's Happening
|
||||
|
||||
```
|
||||
valueset → VID, SiteID, VSetID, VValue, VDesc, VCategory
|
||||
valuesetdef → VSetID, VSName, VSDesc
|
||||
```
|
||||
|
||||
**Every. Single. Enum.** In the entire system is crammed into ONE table:
|
||||
- Gender
|
||||
- Country
|
||||
- Religion
|
||||
- Ethnicity
|
||||
- Marital Status
|
||||
- Death Indicator
|
||||
- Location Type
|
||||
- Test Type
|
||||
- Site Type
|
||||
- Site Class
|
||||
- And probably what you had for lunch
|
||||
|
||||
### Why This Is Catastrophic
|
||||
|
||||
1. **Zero Type Safety**
|
||||
```sql
|
||||
-- This is totally valid and will execute without error:
|
||||
UPDATE patient SET Gender =
|
||||
(SELECT VID FROM valueset WHERE VDesc = 'Hospital' AND VSetID = 'LocationType')
|
||||
```
|
||||
Congratulations, your patient is now a hospital.
|
||||
|
||||
2. **The Join Apocalypse**
|
||||
Getting a single patient with readable values requires this:
|
||||
```php
|
||||
->join('valueset country', 'country.VID = patient.Country', 'left')
|
||||
->join('valueset race', 'race.VID = patient.Race', 'left')
|
||||
->join('valueset religion', 'religion.VID = patient.Religion', 'left')
|
||||
->join('valueset ethnic', 'ethnic.VID = patient.Ethnic', 'left')
|
||||
->join('valueset gender', 'gender.VID = patient.Gender', 'left')
|
||||
->join('valueset deathindicator', 'deathindicator.VID = patient.DeathIndicator', 'left')
|
||||
->join('valueset maritalstatus', 'maritalstatus.VID = patient.MaritalStatus', 'left')
|
||||
```
|
||||
That's **7 joins to the same table** for ONE patient query. Your database server is crying.
|
||||
|
||||
3. **No Easy Identification**
|
||||
- Primary key is `VID` — an auto-increment integer
|
||||
- No natural key enforcement
|
||||
- You want "Male"? Good luck remembering if it's VID 47 or VID 174
|
||||
|
||||
4. **Maintenance Nightmare**
|
||||
- Adding a new Gender option? Better hope you remember the correct `VSetID`
|
||||
- Want to rename "Other" to "Non-Binary"? Hope you update it in the right row
|
||||
- Database constraints? What are those?
|
||||
|
||||
### What Sane People Do
|
||||
|
||||
```sql
|
||||
-- Option A: Actual ENUMs (MySQL)
|
||||
ALTER TABLE patient ADD COLUMN gender ENUM('M', 'F', 'O', 'U');
|
||||
|
||||
-- Option B: Dedicated lookup tables with meaningful constraints
|
||||
CREATE TABLE gender (
|
||||
code VARCHAR(2) PRIMARY KEY,
|
||||
description VARCHAR(50) NOT NULL
|
||||
);
|
||||
ALTER TABLE patient ADD FOREIGN KEY (gender_code) REFERENCES gender(code);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 🪆 Organization: The Matryoshka Nightmare
|
||||
|
||||
### The Current "Architecture"
|
||||
|
||||
```
|
||||
Account → has Parent (self-referencing)
|
||||
↳ has Sites
|
||||
↳ Site → has Parent (self-referencing)
|
||||
↳ has SiteTypeID → valueset
|
||||
↳ has SiteClassID → valueset
|
||||
↳ has Departments
|
||||
↳ Department → has DisciplineID
|
||||
↳ Discipline → has Parent (self-referencing)
|
||||
|
||||
Plus: Workstations exist somewhere in this chaos
|
||||
```
|
||||
|
||||
### The Philosophical Question
|
||||
|
||||
**What IS an organization in this system?**
|
||||
|
||||
| Entity | Has Parent? | Has Site? | Has Account? | Is Self-Referencing? |
|
||||
|--------|-------------|-----------|--------------|----------------------|
|
||||
| Account | ✅ | ❌ | ❌ | ✅ |
|
||||
| Site | ✅ | ❌ | ✅ | ✅ |
|
||||
| Department | ❌ | ✅ | ❌ | ❌ |
|
||||
| Discipline | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
So to understand organizational hierarchy, you need to:
|
||||
1. Traverse Account's parent chain
|
||||
2. For each Account, get Sites
|
||||
3. Traverse each Site's parent chain
|
||||
4. For each Site, get Departments AND Disciplines
|
||||
5. Traverse each Discipline's parent chain
|
||||
6. Oh and don't forget Workstations
|
||||
|
||||
You basically need a graph database to query what should be a simple org chart.
|
||||
|
||||
### What Normal Systems Do
|
||||
|
||||
```sql
|
||||
CREATE TABLE organization (
|
||||
id INT PRIMARY KEY,
|
||||
parent_id INT REFERENCES organization(id),
|
||||
type ENUM('company', 'site', 'department', 'discipline'),
|
||||
name VARCHAR(255),
|
||||
code VARCHAR(50)
|
||||
);
|
||||
```
|
||||
|
||||
**One table. One parent reference. Done.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 📍 Location + LocationAddress: The Pointless Split
|
||||
|
||||
### The Crime
|
||||
|
||||
```
|
||||
location → LocationID, SiteID, LocCode, Parent, LocFull, LocType
|
||||
locationaddress → LocationID, Street1, Street2, City, Province, PostCode, GeoLocation
|
||||
```
|
||||
|
||||
**LocationAddress uses LocationID as both Primary Key AND Foreign Key.**
|
||||
|
||||
This means:
|
||||
- Every location has **exactly one** address (1:1 relationship)
|
||||
- You cannot have a location without an address
|
||||
- You cannot have multiple addresses per location
|
||||
|
||||
### Evidence of the Crime
|
||||
|
||||
```php
|
||||
public function saveLocation(array $data): array {
|
||||
$db->transBegin();
|
||||
try {
|
||||
if (!empty($data['LocationID'])) {
|
||||
$this->update($LocationID, $data);
|
||||
$modelAddress->update($LocationID, $data); // <-- Always update BOTH
|
||||
} else {
|
||||
$LocationID = $this->insert($data, true);
|
||||
$modelAddress->insert($data); // <-- Always insert BOTH
|
||||
}
|
||||
$db->transCommit();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You **always** have to save both tables in a transaction. Because they are fundamentally **ONE entity**.
|
||||
|
||||
### The Verdict
|
||||
|
||||
If data is always created, updated, and deleted together — **IT BELONGS IN THE SAME TABLE**.
|
||||
|
||||
```sql
|
||||
-- Just combine them:
|
||||
CREATE TABLE location (
|
||||
location_id INT PRIMARY KEY,
|
||||
site_id INT,
|
||||
loc_code VARCHAR(50),
|
||||
loc_full VARCHAR(255),
|
||||
loc_type INT,
|
||||
street1 VARCHAR(255),
|
||||
street2 VARCHAR(255),
|
||||
city INT,
|
||||
province INT,
|
||||
post_code VARCHAR(20),
|
||||
geo_location_system VARCHAR(50),
|
||||
geo_location_data TEXT
|
||||
);
|
||||
```
|
||||
|
||||
You just saved yourself a transaction, a join, and 50% of the headache.
|
||||
|
||||
---
|
||||
|
||||
## 4. 👨⚕️ Contact vs Doctor: The Identity Crisis
|
||||
|
||||
### The Confusion
|
||||
|
||||
```
|
||||
contact → ContactID, NameFirst, NameLast, Specialty, SubSpecialty, Phone...
|
||||
contactdetail → ContactDetID, ContactID, SiteID, OccupationID, JobTitle, Department...
|
||||
occupation → OccupationID, OccupationName...
|
||||
```
|
||||
|
||||
### The Questions Nobody Can Answer
|
||||
|
||||
1. **Is a Contact a Doctor?**
|
||||
- Contact has `Specialty` and `SubSpecialty` fields (doctor-specific)
|
||||
- But also has generic `OccupationID` via ContactDetail
|
||||
- So a Contact is Maybe-A-Doctor™?
|
||||
|
||||
2. **What prevents non-doctors from being assigned as doctors?**
|
||||
|
||||
In `patvisitadt`:
|
||||
```php
|
||||
'AttDoc', 'RefDoc', 'AdmDoc', 'CnsDoc' // Attending, Referring, Admitting, Consulting
|
||||
```
|
||||
|
||||
These store ContactIDs. But there's **zero validation** that these contacts are actually doctors. Your receptionist could be the Attending Physician and the database would happily accept it.
|
||||
|
||||
3. **Why does ContactDetail exist?**
|
||||
- It stores the same person's info **per site**
|
||||
- So one person can have different roles at different sites
|
||||
- But `Specialty` is on Contact (not ContactDetail), so a doctor has the same specialty everywhere?
|
||||
- Except `OccupationID` is on ContactDetail, so their occupation changes per site?
|
||||
|
||||
### The Solution
|
||||
|
||||
```sql
|
||||
CREATE TABLE person (
|
||||
person_id INT PRIMARY KEY,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
-- ... basic personal info
|
||||
);
|
||||
|
||||
CREATE TABLE doctor (
|
||||
doctor_id INT PRIMARY KEY REFERENCES person(person_id),
|
||||
specialty VARCHAR(100),
|
||||
subspecialty VARCHAR(100),
|
||||
license_number VARCHAR(50)
|
||||
);
|
||||
|
||||
CREATE TABLE site_staff (
|
||||
person_id INT REFERENCES person(person_id),
|
||||
site_id INT REFERENCES site(site_id),
|
||||
role ENUM('doctor', 'nurse', 'technician', 'admin'),
|
||||
PRIMARY KEY (person_id, site_id)
|
||||
);
|
||||
```
|
||||
|
||||
Now you can actually **enforce** that only doctors are assigned to doctor fields.
|
||||
|
||||
---
|
||||
|
||||
## 5. 🏥 Patient Data: The Table Explosion
|
||||
|
||||
### The Current State
|
||||
|
||||
| Table | Purpose | Why It Exists |
|
||||
|-------|---------|---------------|
|
||||
| `patient` | Main patient (30+ columns) | Fair enough |
|
||||
| `patidt` | Patient Identifiers | One identifier per patient |
|
||||
| `patcom` | Patient Comments | ONE comment per patient (why a whole table?) |
|
||||
| `patatt` | Patient "Attachments"... or Addresses? | Stores `Address` as a single string |
|
||||
|
||||
### The Crimes
|
||||
|
||||
#### Crime 1: Duplicate Address Storage
|
||||
|
||||
`patient` table already has:
|
||||
```
|
||||
Street_1, Street_2, Street_3, City, Province, ZIP
|
||||
```
|
||||
|
||||
`patatt` table stores:
|
||||
```
|
||||
Address (as a single string)
|
||||
```
|
||||
|
||||
**Why do we have both?** Nobody knows. Pick one and commit.
|
||||
|
||||
#### Crime 2: One Table for ONE Comment
|
||||
|
||||
```php
|
||||
class PatComModel {
|
||||
protected $allowedFields = ['InternalPID', 'Comment', 'CreateDate', 'EndDate'];
|
||||
|
||||
public function createPatCom(string $patcom, string $newInternalPID) {
|
||||
$this->insert(["InternalPID" => $newInternalPID, "Comment" => $patcom]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A whole table. Foreign key constraints. Model class. CRUD operations. **For one text field.**
|
||||
|
||||
Just add `comment TEXT` to the patient table.
|
||||
|
||||
#### Crime 3: CSV in a Relational Database
|
||||
|
||||
```php
|
||||
$patient['LinkTo'] = '1,5,23,47'; // Comma-separated patient IDs
|
||||
```
|
||||
|
||||
```php
|
||||
private function getLinkedPatients(?string $linkTo): ?array {
|
||||
$ids = array_filter(explode(',', $linkTo)); // Oh no
|
||||
return $this->db->table('patient')->whereIn('InternalPID', $ids)->get();
|
||||
}
|
||||
```
|
||||
|
||||
**It's 2025.** Use a junction table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE patient_link (
|
||||
patient_id INT,
|
||||
linked_patient_id INT,
|
||||
link_type VARCHAR(50),
|
||||
PRIMARY KEY (patient_id, linked_patient_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 🤮 Patient Admission (ADT): Event Sourcing Gone Wrong
|
||||
|
||||
### The Pattern
|
||||
|
||||
```
|
||||
patvisit → InternalPVID, PVID, InternalPID (the visit)
|
||||
patvisitadt → PVADTID, InternalPVID, ADTCode, LocationID, AttDoc... (ADT events)
|
||||
patdiag → InternalPVID, DiagCode, Diagnosis
|
||||
```
|
||||
|
||||
Instead of tracking patient status with simple fields, every Admission/Discharge/Transfer creates a **new row**.
|
||||
|
||||
### The Query From Hell
|
||||
|
||||
To get the current status of a patient visit:
|
||||
|
||||
```php
|
||||
->join('(SELECT a1.*
|
||||
FROM patvisitadt a1
|
||||
INNER JOIN (
|
||||
SELECT InternalPVID, MAX(PVADTID) AS MaxID
|
||||
FROM patvisitadt
|
||||
GROUP BY InternalPVID
|
||||
) a2 ON a1.InternalPVID = a2.InternalPVID AND a1.PVADTID = a2.MaxID
|
||||
) AS patvisitadt',
|
||||
'patvisitadt.InternalPVID = patvisit.InternalPVID',
|
||||
'left')
|
||||
```
|
||||
|
||||
Every. Single. Query. To get current patient status.
|
||||
|
||||
### The Performance Analysis
|
||||
|
||||
| Rows in patvisitadt | Query Complexity |
|
||||
|---------------------|------------------|
|
||||
| 1,000 | Meh, fine |
|
||||
| 10,000 | Getting slow |
|
||||
| 100,000 | Coffee break |
|
||||
| 1,000,000 | Go home |
|
||||
|
||||
### What You Should Do
|
||||
|
||||
**Option A: Just use status fields**
|
||||
```sql
|
||||
ALTER TABLE patvisit ADD COLUMN current_status ENUM('admitted', 'discharged', 'transferred');
|
||||
ALTER TABLE patvisit ADD COLUMN current_location_id INT;
|
||||
ALTER TABLE patvisit ADD COLUMN current_attending_doctor_id INT;
|
||||
```
|
||||
|
||||
**Option B: If you NEED history, use proper triggers**
|
||||
```sql
|
||||
CREATE TRIGGER update_current_status
|
||||
AFTER INSERT ON patvisitadt
|
||||
FOR EACH ROW
|
||||
UPDATE patvisit SET current_status = NEW.adt_code WHERE InternalPVID = NEW.InternalPVID;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 🧪 Test Definitions: The Abbreviation Cemetery
|
||||
|
||||
### The Tables
|
||||
|
||||
| Table | What Is This? | Fields |
|
||||
|-------|---------------|--------|
|
||||
| `testdefsite` | Test per Site | TestSiteID, TestSiteCode, TestSiteName, SeqScr, SeqRpt, VisibleScr, VisibleRpt... |
|
||||
| `testdefgrp` | Test Groups | TestGrpID, TestSiteID, Member |
|
||||
| `testdefcal` | Calculated Tests | TestCalID, TestSiteID, DisciplineID, DepartmentID... |
|
||||
| `testdeftech` | Technical Details | TestTechID, TestSiteID, DisciplineID, DepartmentID... |
|
||||
| `testmap` | ??? | TestMapID, TestSiteID... |
|
||||
| `refnum` | Numeric Reference Ranges | RefNumID, TestSiteID, Sex, AgeStart, AgeEnd, Low, High... |
|
||||
| `reftxt` | Text Reference Ranges | RefTxtID, TestSiteID, Sex, AgeStart, AgeEnd, RefTxt... |
|
||||
| `refvset` | ValueSet Reference | RefVSetID, TestSiteID... |
|
||||
| `refthold` | Thresholds | RefTHoldID... |
|
||||
|
||||
### The Abbreviation Apocalypse
|
||||
|
||||
| Abbreviation | Meaning | Guessability |
|
||||
|--------------|---------|--------------|
|
||||
| `SeqScr` | Sequence Screen | 2/10 |
|
||||
| `SeqRpt` | Sequence Report | 3/10 |
|
||||
| `SpcType` | Specimen Type | 4/10 |
|
||||
| `VID` | ValueSet ID | 1/10 |
|
||||
| `InternalPID` | Internal Patient ID | 5/10 |
|
||||
| `InternalPVID` | Internal Patient Visit ID | 4/10 |
|
||||
| `PVADTID` | Patient Visit ADT ID | 0/10 |
|
||||
| `TestDefCal` | Test Definition Calculation | 3/10 |
|
||||
| `RefTHold` | Reference Threshold | 1/10 |
|
||||
|
||||
**Pro tip:** If new developers need a glossary to understand your schema, you've failed.
|
||||
|
||||
### The Split Between RefNum and RefTxt
|
||||
|
||||
For numeric tests: use `refnum`
|
||||
For text tests: use `reftxt`
|
||||
|
||||
Why not:
|
||||
```sql
|
||||
CREATE TABLE reference_range (
|
||||
id INT PRIMARY KEY,
|
||||
test_id INT,
|
||||
range_type ENUM('numeric', 'text'),
|
||||
low_value DECIMAL,
|
||||
high_value DECIMAL,
|
||||
text_value VARCHAR(255),
|
||||
-- ... other fields
|
||||
);
|
||||
```
|
||||
|
||||
One table. One query. One life.
|
||||
|
||||
---
|
||||
|
||||
## 8. 🎭 Bonus Round: Sins I Couldn't Ignore
|
||||
|
||||
### Sin #1: Inconsistent Soft Delete Field Names
|
||||
|
||||
| Table | Delete Field | Why Different? |
|
||||
|-------|--------------|----------------|
|
||||
| Most tables | `EndDate` | ??? |
|
||||
| patient | `DelDate` | ??? |
|
||||
| patatt | `DelDate` | ??? |
|
||||
| patvisitadt | `EndDate` AND `ArchivedDate` AND `DelDate` | ¯\\\_(ツ)\_/¯ |
|
||||
|
||||
### Sin #2: Primary Key With a Trailing Space
|
||||
|
||||
In `PatComModel.php`:
|
||||
```php
|
||||
protected $primaryKey = 'PatComID '; // <-- THERE IS A SPACE HERE
|
||||
```
|
||||
|
||||
This has either:
|
||||
- Never been tested
|
||||
- Works by pure accident
|
||||
- Will explode randomly one day
|
||||
|
||||
### Sin #3: Inconsistent ID Naming
|
||||
|
||||
| Column | Location |
|
||||
|--------|----------|
|
||||
| `InternalPID` | patient, patatt, patcom, patidt, patvisit... |
|
||||
| `PatientID` | Also on patient table |
|
||||
| `ContactID` | contact |
|
||||
| `ContactDetID` | contactdetail |
|
||||
| `VID` | valueset |
|
||||
| `VSetID` | Also valueset and valuesetdef |
|
||||
| `TestSiteID` | testdefsite |
|
||||
| `TestGrpID` | testdefgrp |
|
||||
|
||||
Pick a convention. ANY convention. Please.
|
||||
|
||||
### Sin #4: Multiple Date Tracking Fields with Unclear Purposes
|
||||
|
||||
On `testdefsite`:
|
||||
- `CreateDate` — when created
|
||||
- `StartDate` — when... started? Different from created how?
|
||||
- `EndDate` — when ended (soft delete)
|
||||
|
||||
### Sin #5: No Data Validation
|
||||
|
||||
The `patient` model has 30+ fields including:
|
||||
- `Gender` (valueset VID — could be literally anything)
|
||||
- `Religion` (valueset VID — could be a LocationType)
|
||||
- `DeathIndicator` (valueset VID — could be a Gender)
|
||||
|
||||
Zero database-level constraints. Zero model-level validation. Pure vibes.
|
||||
|
||||
---
|
||||
|
||||
## 🏆 The Final Scorecard
|
||||
|
||||
| Category | Rating | Notes |
|
||||
|----------|--------|-------|
|
||||
| **Normalization** | 2/10 | Either over-normalized (LocationAddress) or under-normalized (CSV in columns) |
|
||||
| **Consistency** | 1/10 | Every table is a unique snowflake |
|
||||
| **Performance** | 3/10 | Those MAX subqueries and 7-way joins will age poorly |
|
||||
| **Maintainability** | 1/10 | Good luck onboarding new developers |
|
||||
| **Type Safety** | 0/10 | ValueSet is a type-safety black hole |
|
||||
| **Naming** | 2/10 | Abbreviation chaos |
|
||||
| **Scalability** | 2/10 | Event-sourcing-but-wrong will not scale |
|
||||
|
||||
**Overall: 1.5/10** — *"At least the tables exist"*
|
||||
|
||||
---
|
||||
|
||||
## 💡 Recommendations
|
||||
|
||||
If you want to fix this (and you should), here's the priority order:
|
||||
|
||||
1. **Eliminate the ValueSet monster** — Replace with proper ENUMs or dedicated lookup tables with foreign key constraints
|
||||
|
||||
2. **Combine 1:1 tables** — Location + LocationAddress, Patient + PatCom (if it's really just one comment)
|
||||
|
||||
3. **Fix Patient data model** — Proper junction tables for PatIdt, PatAtt, and LinkTo
|
||||
|
||||
4. **Add current status to PatVisit** — Denormalize the ADT current state
|
||||
|
||||
5. **Standardize naming** — Pick `*_id`, `*ID`, or `Id` and stick with it. Pick `end_date` or `del_date` for soft deletes.
|
||||
|
||||
6. **Add actual constraints** — Foreign keys that make sense. Check constraints. Not just vibes.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Conclusion
|
||||
|
||||
This schema is what happens when someone:
|
||||
- Prioritizes "flexibility" over usability
|
||||
- Learns about normalization but not when to stop
|
||||
- Discovers self-referencing tables and uses them everywhere
|
||||
- Thinks abbreviations save storage space (they don't)
|
||||
- Has never had to maintain their own code
|
||||
|
||||
The good news: it can be fixed.
|
||||
The bad news: it should have been designed correctly the first time.
|
||||
|
||||
---
|
||||
|
||||
*Document prepared with 🔥 and ☕*
|
||||
|
||||
*May your database queries be fast and your schemas be sane.*
|
||||
170
src/blog/clqms-roast-zai.md
Normal file
170
src/blog/clqms-roast-zai.md
Normal file
@ -0,0 +1,170 @@
|
||||
---
|
||||
title: "Database Design Roast"
|
||||
description: "A legendary roast of the CLQMS database schema, highlighting the architectural hazards and data hell."
|
||||
date: 2026-01-07
|
||||
order: 7
|
||||
tags:
|
||||
- posts
|
||||
- clqms
|
||||
layout: clqms-post.njk
|
||||
---
|
||||
|
||||
# The Database Design from Hell: A Comprehensive Roast
|
||||
## *Or: Why your current project makes you want to quit.*
|
||||
|
||||
You are right to be sick of this. This document is a masterclass in how **not** to design a database. It takes simple concepts and wraps them in layers of unnecessary, redundant, and contradictory complexity.
|
||||
|
||||
Here is the systematic destruction of every data relation and design choice that is causing you pain.
|
||||
|
||||
---
|
||||
|
||||
## 1. The "Value Set" Disaster (The God Table)
|
||||
|
||||
**The Design:**
|
||||
Storing every single enumeration (Dropdown list, Flag, Reference Text, Status) in one giant generic table (`ValueSet` / `codedtxtfld`).
|
||||
|
||||
**The Roast:**
|
||||
* **No Identity:** You mentioned "don't have an easy identifier." This is because they likely didn't use a Surrogate Key (an auto-incrementing ID). They probably used the `Code` (e.g., "M" for Male) as the Primary Key.
|
||||
* *Why it sucks:* If you ever need to change "M" to "Male", you break every single Foreign Key in the database that points to it. You can't update a key that is being used elsewhere. Your data is frozen in time forever.
|
||||
* **Performance Killer:** Imagine loading a dropdown for "Gender." The database has to scan a table containing 10,000 rows (all flags, all statuses, all colors, all text results) just to find "M" and "F".
|
||||
* **Zero Integrity:** Because it's a generic table, you can't enforce specific rules. You can accidentally delete a value used for "Critical Patient Status" because the table thinks it's just a generic string. There is no referential integrity. It's the Wild West.
|
||||
|
||||
---
|
||||
|
||||
## 2. The "Organization" Nightmare
|
||||
|
||||
**The Design:**
|
||||
Attempting to model a global conglomerate structure of Accounts, Sites, Disciplines, Departments, Workstations, and Parent-Child hierarchies.
|
||||
|
||||
**The Roast:**
|
||||
* **Over-Engineering:** You are building a database for a Laboratory, not the United Nations. Why does a lab system need a recursive `Account` structure that handles "Parent/Child" relationships for companies?
|
||||
* **The Blob:** `Account` comes from CRM, but `Site` comes from CRM, yet `Discipline` is internal. You are mixing external business logic (Sales) with internal operational logic (Lab Science).
|
||||
* **Identity Confusion:** Is a "Department" a physical place? A group of people? Or a billing category? In this design, it's all three simultaneously. This makes generating a simple report like "Who works in Hematology?" a complex query involving `Organization`, `Personnel`, and `Location`.
|
||||
|
||||
---
|
||||
|
||||
## 3. The "Location" & "LocationAddress" Split
|
||||
|
||||
**The Design:**
|
||||
Separating the Location definition (Name, Type) from its Address (Street, City) into two different tables linked 1-to-1.
|
||||
|
||||
**The Roast:**
|
||||
* **The "Why?" Factor:** Why? Is a Bed (Location) going to have multiple addresses? No. Is a Building going to have multiple addresses? No.
|
||||
* **Performance Tax:** Every single time you need to print a label or show where a sample is, you **must** perform a `JOIN`.
|
||||
* *Bad Design:* `SELECT * FROM Location l JOIN LocationAddress a ON l.id = a.id`
|
||||
* **The Null Nightmare:** For mobile locations (Home Care), the address is vital. For static locations (Bed 1), the address is meaningless (it's just coordinates relative to the room). By forcing a split, you either have empty rows in `LocationAddress` or you have to invent fake addresses for beds. It's pointless normalization.
|
||||
|
||||
---
|
||||
|
||||
## 4. The "HostApp" Table
|
||||
|
||||
**The Design:**
|
||||
A specific table dedicated to defining external applications (`HostApp`) that integrate with the system.
|
||||
|
||||
**The Roast:**
|
||||
* **The Myth of Modularity:** This table pretends the system is "plug-and-play" with other apps. But look at the Appendices: "Calibration Results SQL Scripts." That's hard-coded SQL, not a dynamic plugin.
|
||||
* **Maintenance Hell:** This table implies you need to map every single field from every external app.
|
||||
* *Scenario:* Hospital A uses HIS "MediTech". Hospital B uses HIS "CarePoint".
|
||||
* *Result:* You need a `HostApp` row for MediTech and one for CarePoint. Then you need mapping tables for Patient, Order, Result, etc. You are building an ETL (Extract, Transform, Load) tool inside a Lab database. It's out of scope.
|
||||
|
||||
---
|
||||
|
||||
## 5. The "Doctor" vs "Contact" Loop
|
||||
|
||||
**The Design:**
|
||||
A `Contact` table that stores generic people, and a `Doctor` table that... also stores people? Or does it reference Contact?
|
||||
|
||||
**The Roast:**
|
||||
* **The Infinite Join:** To get a Doctor's name, do you query `Doctor` or `Contact`?
|
||||
* If `Doctor` extends `Contact` (1-to-1), you have to join every time.
|
||||
* If `Doctor` is just a row in `Contact` with a `Type='Doctor'`, why does the `Doctor` table exist?
|
||||
* **Semantic Mess:** A "Contact" usually implies "How to reach." A "Doctor" implies "Medical License."
|
||||
* **The Failure:** If Dr. Smith retires, do you delete the `Doctor` record? Yes. But then you delete his `Contact` info, so you lose his phone number for historical records. This design doesn't separate the *Role* (Doctor) from the *Entity* (Person). It's a data integrity nightmare.
|
||||
|
||||
---
|
||||
|
||||
## 6. Patient Data: The Actual Hell
|
||||
|
||||
**The Design:**
|
||||
Storing Patients, Non-Patients (Blood bags), External QC, and linking/unlinking them all in one messy structure.
|
||||
|
||||
**The Roast:**
|
||||
* **Blood Bags are not Humans:**
|
||||
* The document explicitly says "mengelola non-patient entity... blood bag."
|
||||
* *Result:* Your `Patient` table has `DateOfBirth` (Required) and `BloodType` (Required). For a Blood Bag, DOB is NULL. For a Human, BloodType might be NULL.
|
||||
* You have created a "Sparse Table" (50% NULLs). It's impossible to index effectively. It breaks the very definition of what a "Patient" is.
|
||||
* **The "Link/Unlink" Suicide Pact:**
|
||||
* "Menghubungkan (link)/mengurai(unlink) data pasien."
|
||||
* *Audit Trail Death:* If I link "John Doe" from Site A to "John Doe" from Site B, and then later "Unlink" them, the database loses the history of that decision. Why did we unlink them? Was it a mistake? The design doesn't track the *decision*, it just changes the data.
|
||||
* **Confusion:** `Patient Registration` vs `Patient Admission`. Why are these two different giant workflows? In every other system on earth, you Register (create ID) and Admit (start visit). This document treats them like they require NASA-level calculations.
|
||||
|
||||
---
|
||||
|
||||
## 7. Patient Admission: Revenue Cycle Virulence
|
||||
|
||||
**The Design:**
|
||||
Admission is tightly coupled with "Pihak yang menanggung biaya" (Payer) and "Tarif" (Price).
|
||||
|
||||
**The Roast:**
|
||||
* **It's a Billing System, not a Lab System:** This section reads like an Accounting module. The Lab database should not care if the patient pays with BlueCross or Cash.
|
||||
* **The Logic Trap:** If the Admission fails because the "Tarif" (Price) is missing, does the Lab stop processing the blood sample?
|
||||
* *According to this design:* Probably yes.
|
||||
* *In reality:* The patient could be dying. Clinical safety should never be blocked by administrative billing data. Mixing these concerns is dangerous.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Data: The Maze of Redundancy
|
||||
|
||||
**The Design:**
|
||||
`testdef`, `testdefsite`, `testdeftech`, `testgrp`, `refnum`, `reftxt`, `fixpanel`.
|
||||
|
||||
**The Roast:**
|
||||
* **Definition Explosion:**
|
||||
* `testdefsite`: Defines "Glucose" for Site A.
|
||||
* `testdeftech`: Defines "Glucose" for Machine B.
|
||||
* *Reality:* Glucose is Glucose. The chemical reaction doesn't change because the building is different.
|
||||
* *Cost:* You have to update the Reference Range for Glucose in 50 different places if the lab director decides to change it.
|
||||
* **The "Group" Soup:**
|
||||
* `Profile` (One tube), `Functional Procedure` (Time series), `Superset` (Billing).
|
||||
* These are stored in `testgrp` as if they are the same thing.
|
||||
* *Failure:** You can't enforce logic. The system allows you to add a "2-Hour Post-Prandial" (Time-based) to a "Lipid Panel" (One-tube) because to the database, they are just "Tests in a Group."
|
||||
* **`refnum` vs `reftxt`:**
|
||||
* Why split them? A Reference Range is data. Whether it's "10-20" (Numeric) or "Positive/Negative" (Text) is just a formatting rule. Splitting them into two tables doubles your JOINs and complicates the query logic for no reason.
|
||||
|
||||
---
|
||||
|
||||
## 9. BONUS: Other Disasters I Found
|
||||
|
||||
### A. Equipment & The "Mousepad" Tracking
|
||||
**The Roast:**
|
||||
The document defines Equipment as: "termasuk UPS, AVR, printer, PC... mouse, keyboard."
|
||||
* **The Trap:** Do you really need a depreciation schedule and maintenance log for a mouse? By lumping "IVD Analyzers" (Critical Medical Devices) with "Computer Mice" (Office Supplies), you clutter the Equipment table with garbage data.
|
||||
* **Fix:** Separate `CapitalAsset` (Machines) from `InventoryItem` (Supplies).
|
||||
|
||||
### B. Specimen: The "Parent" Trap
|
||||
**The Roast:**
|
||||
Secondary Specimen has a `ParentID` pointing to Primary Specimen.
|
||||
* **The Void:** There is no tracking of volume. If Tube A (Parent) has 5ml, and you create Tube B (Child/Aliquot) with 1ml, the database does not know that Tube A now only has 4ml left.
|
||||
* **The Consequence:** You cannot do automated inventory. You can't alert the user "Running low on sample!" because the database thinks the sample is infinite.
|
||||
|
||||
### C. The "Red Font" Encryption
|
||||
**The Roast:**
|
||||
"Encrypted = Red Font."
|
||||
* **The Joke:** This is technically illiterate. A database stores bytes. It does not store "Red Font."
|
||||
* **The Risk:** If they literally store `<b><span color='red'>PatientName</span></b>` in the database, you have corrupted your data.
|
||||
* **The Reality:** If it's just a UI setting, why is it in the database requirements section? It proves the writer doesn't know the difference between Data Storage and Data Presentation.
|
||||
|
||||
---
|
||||
|
||||
## Summary: Why you want to vomit
|
||||
|
||||
This document is a "Jack of All Trades, Master of None."
|
||||
It tries to be:
|
||||
1. **CRM** (Contact/Account mgmt)
|
||||
2. **ERP** (Inventory/Asset mgmt)
|
||||
3. **Billing System** (Admission/Tariff)
|
||||
4. **Laboratory Information System** (The actual work)
|
||||
|
||||
By trying to do all of this in a single, poorly normalized schema, it creates a **Data Hell** where nothing is reliable, nothing is fast, and changing one thing breaks three others.
|
||||
|
||||
**Do not build this "as is."** If you do, you will spend the next 5 years writing `SQL Scripts` to patch the holes in this sinking ship.
|
||||
@ -2,7 +2,7 @@
|
||||
title: "Project Pandaria: Next-Gen LIS Architecture"
|
||||
description: "An offline-first, event-driven architecture concept for the CLQMS."
|
||||
date: 2025-12-19
|
||||
order: 6
|
||||
order: 8
|
||||
tags:
|
||||
- posts
|
||||
- clqms
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
title: "Edge Workstation: SQLite Database Schema"
|
||||
description: "Database design for the offline-first smart workstation."
|
||||
date: 2025-12-19
|
||||
order: 7
|
||||
order: 9
|
||||
tags:
|
||||
- posts
|
||||
- clqms
|
||||
|
||||
@ -16,7 +16,7 @@ description: Our projects and technical showcase
|
||||
</p>
|
||||
</div>
|
||||
<!-- Proposals List -->
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<div class="space-y-6">
|
||||
{% for post in collections.posts %}
|
||||
<a href="{{ post.url }}" class="post-card block group">
|
||||
<div class="flex flex-col md:flex-row md:items-start gap-4">
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
/* ========================================
|
||||
THEME CONFIGURATION
|
||||
@ -62,78 +61,53 @@
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
DAISYUI THEME CUSTOMIZATION
|
||||
CSS CUSTOM PROPERTIES - THEME COLORS
|
||||
======================================== */
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: panda --default, pandaLight;
|
||||
}
|
||||
:root {
|
||||
/* Default: Dark theme with blue accents */
|
||||
--color-base-100: oklch(0.15 0.02 250);
|
||||
--color-base-200: oklch(0.18 0.025 250);
|
||||
--color-base-300: oklch(0.25 0.03 250);
|
||||
--color-base-content: oklch(0.95 0.01 250);
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "panda";
|
||||
default: true;
|
||||
/* Fun dark theme - cozy night with pops of color */
|
||||
--color-base-100: oklch(0.18 0.015 280);
|
||||
/* Deep purple-ish dark */
|
||||
--color-base-200: oklch(0.22 0.02 280);
|
||||
/* Slightly lighter */
|
||||
--color-base-300: oklch(0.30 0.025 280);
|
||||
/* Cards and borders */
|
||||
--color-base-content: oklch(0.95 0.01 280);
|
||||
/* Crisp white text */
|
||||
--color-primary: oklch(0.75 0.18 15);
|
||||
/* 🧡 Coral/Peach - warm and fun */
|
||||
--color-primary-content: oklch(0.15 0.02 15);
|
||||
--color-secondary: oklch(0.70 0.20 300);
|
||||
/* 💜 Electric Purple - playful */
|
||||
--color-secondary-content: oklch(1 0 0);
|
||||
--color-accent: oklch(0.78 0.15 175);
|
||||
/* 🌊 Minty Teal - fresh pop */
|
||||
--color-accent-content: oklch(0.15 0.02 175);
|
||||
--color-neutral: oklch(0.28 0.02 280);
|
||||
--color-neutral-content: oklch(0.92 0.01 280);
|
||||
--color-primary: oklch(0.70 0.20 240);
|
||||
--color-primary-content: oklch(1 0 0);
|
||||
--color-secondary: oklch(0.75 0.18 200);
|
||||
--color-secondary-content: oklch(0.15 0.02 200);
|
||||
--color-accent: oklch(0.65 0.25 260);
|
||||
--color-accent-content: oklch(1 0 0);
|
||||
--color-neutral: oklch(0.28 0.02 250);
|
||||
--color-neutral-content: oklch(0.92 0.01 250);
|
||||
--color-info: oklch(0.72 0.14 230);
|
||||
/* Sky blue */
|
||||
--color-success: oklch(0.75 0.18 155);
|
||||
/* Lime green */
|
||||
--color-warning: oklch(0.82 0.16 85);
|
||||
/* Sunny yellow */
|
||||
--color-error: oklch(0.68 0.20 25);
|
||||
/* Soft red */
|
||||
|
||||
--radius-box: 1.25rem;
|
||||
--radius-btn: 0.75rem;
|
||||
--radius-badge: 9999px;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "pandaLight";
|
||||
/* Fun light theme - bright and cheerful */
|
||||
--color-base-100: oklch(0.99 0.005 280);
|
||||
/* Off-white */
|
||||
--color-base-200: oklch(0.96 0.01 280);
|
||||
/* Light lavender tint */
|
||||
--color-base-300: oklch(0.92 0.015 280);
|
||||
/* Subtle purple-gray */
|
||||
--color-base-content: oklch(0.25 0.03 280);
|
||||
/* Dark purple text */
|
||||
--color-primary: oklch(0.65 0.20 15);
|
||||
/* 🧡 Coral - punchy */
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--color-base-100: oklch(0.99 0.005 240);
|
||||
--color-base-200: oklch(0.96 0.01 240);
|
||||
--color-base-300: oklch(0.92 0.015 240);
|
||||
--color-base-content: oklch(0.25 0.03 240);
|
||||
|
||||
--color-primary: oklch(0.55 0.25 240);
|
||||
--color-primary-content: oklch(1 0 0);
|
||||
--color-secondary: oklch(0.58 0.22 300);
|
||||
/* 💜 Rich Purple */
|
||||
--color-secondary: oklch(0.60 0.22 200);
|
||||
--color-secondary-content: oklch(1 0 0);
|
||||
--color-accent: oklch(0.65 0.18 175);
|
||||
/* 🌊 Teal */
|
||||
--color-accent: oklch(0.50 0.28 260);
|
||||
--color-accent-content: oklch(1 0 0);
|
||||
--color-neutral: oklch(0.45 0.03 280);
|
||||
--color-neutral: oklch(0.45 0.03 240);
|
||||
--color-neutral-content: oklch(0.98 0 0);
|
||||
--color-info: oklch(0.62 0.16 230);
|
||||
--color-success: oklch(0.65 0.20 155);
|
||||
--color-warning: oklch(0.78 0.18 85);
|
||||
--color-error: oklch(0.58 0.22 25);
|
||||
--radius-box: 1.25rem;
|
||||
--radius-btn: 0.75rem;
|
||||
--radius-badge: 9999px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@ -149,6 +123,9 @@
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@ -157,11 +134,379 @@
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
COMPONENT STYLES
|
||||
COMPONENT STYLES - BUTTONS
|
||||
======================================== */
|
||||
|
||||
@layer components {
|
||||
|
||||
/* Button base */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-btn);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.btn-outline.btn-primary {
|
||||
color: var(--color-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-outline.btn-primary:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
.btn-circle {
|
||||
border-radius: 9999px;
|
||||
padding: 0.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-sm.btn-circle {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
BADGES
|
||||
======================================== */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-badge);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-secondary-content);
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-accent-content);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: var(--color-warning);
|
||||
color: oklch(0.2 0 0);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: var(--color-success);
|
||||
color: oklch(0.2 0 0);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: var(--color-error);
|
||||
color: oklch(1 0 0);
|
||||
}
|
||||
|
||||
.badge-ghost {
|
||||
background-color: var(--color-base-300);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.badge-outline.badge-primary {
|
||||
color: var(--color-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.badge-outline.badge-secondary {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.badge-outline.badge-accent {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.badge-outline.badge-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-outline.badge-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-outline.badge-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.badge-lg {
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
NAVBAR
|
||||
======================================== */
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navbar-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
MENU
|
||||
======================================== */
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.menu-sm li>a,
|
||||
.menu-sm li>button,
|
||||
.menu-sm li details summary {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.menu li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu li>a,
|
||||
.menu li>button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-base-content);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.menu li details {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu li details summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--color-base-content);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu li details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu li details summary::after {
|
||||
content: "";
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.menu li details[open] summary::after {
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
|
||||
.menu li details>ul {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
flex-direction: column;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CARD
|
||||
======================================== */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-box);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FOOTER
|
||||
======================================== */
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
THEME TOGGLE
|
||||
======================================== */
|
||||
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-toggle .icon-sun,
|
||||
.theme-toggle .icon-moon {
|
||||
position: absolute;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle .icon-sun {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.theme-toggle .icon-moon {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle .icon-sun {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle .icon-moon {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ORIGINAL CUSTOM COMPONENTS
|
||||
======================================== */
|
||||
|
||||
/* Gradient text effect */
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, var(--color-primary), var(--color-secondary), var(--color-accent));
|
||||
@ -408,6 +753,138 @@
|
||||
border-top: 1px solid oklch(1 0 0 / 0.1);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TAILWIND COLOR UTILITIES
|
||||
======================================== */
|
||||
|
||||
.bg-base-100 {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
.bg-base-200 {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
.bg-base-300 {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
.text-base-content {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.border-base-200 {
|
||||
border-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
.border-base-300 {
|
||||
border-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hover\:bg-base-100:hover {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
.hover\:bg-base-300:hover {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
.hover\:text-base-content:hover {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.hover\:text-primary:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hover\:text-secondary:hover {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.hover\:text-warning:hover {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.hover\:text-success:hover {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.hover\:text-error:hover {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.hover\:border-primary\/50:hover {
|
||||
border-color: oklch(0.75 0.18 15 / 0.5);
|
||||
}
|
||||
|
||||
.hover\:border-secondary\/50:hover {
|
||||
border-color: oklch(0.70 0.20 300 / 0.5);
|
||||
}
|
||||
|
||||
.hover\:border-warning\/50:hover {
|
||||
border-color: oklch(0.82 0.16 85 / 0.5);
|
||||
}
|
||||
|
||||
.hover\:border-success\/50:hover {
|
||||
border-color: oklch(0.75 0.18 155 / 0.5);
|
||||
}
|
||||
|
||||
.hover\:border-error\/50:hover {
|
||||
border-color: oklch(0.68 0.20 25 / 0.5);
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:text-secondary {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:text-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:text-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:bg-primary\/20 {
|
||||
background-color: oklch(0.75 0.18 15 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user