6 Commits

17 changed files with 686 additions and 3733 deletions
-21
View File
@@ -1,21 +0,0 @@
# Dependencies
node_modules/
# Tailwind build temp
tmp.json
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo
-233
View File
@@ -1,233 +0,0 @@
# AGENTS.md — NGO Landing Page
Read this file fully before taking any action.
---
## Project Context
Single-page static landing page for an NGO ERP product.
| Item | Detail |
|---|---|
| Pages | `index.html` — the only HTML file |
| Styling | Tailwind CSS v3 · source `src/input.css` → compiled `assets/site.css` |
| Data | `blog.json` — flat array of blog posts, loaded at runtime by inline JS |
| i18n | Inline `translations` object inside `index.html` (EN / ES / DE) |
| Blog renderer | Inline `<script>` in `index.html` — no external JS files |
| Dev server | `./start` (runs `live-server`) |
| Git remote | `git@git.odoo4projects.com:Oliver/NGO.git` |
### File map
```
index.html ← only HTML file — layout + all inline JS + i18n
src/input.css ← Tailwind source (edit here, then rebuild)
assets/site.css ← compiled output — DO NOT edit by hand
blog.json ← English blog posts (canonical + fallback)
blog.es.json ← Spanish blog posts
blog.de.json ← German blog posts
tailwind.config.js ← design tokens (colors, fonts, shadows)
```
### Design tokens (from `tailwind.config.js`)
| Token | Hex | Usage |
|---|---|---|
| `brand-ink` | `#201824` | default text / dark backgrounds |
| `brand-primary` | `#603F57` | section labels, bullet accents |
| `brand-accent` | `#F762B4` | CTA buttons, links |
| `brand-sunrise` | `#F8B84A` | hero highlights, hover states |
| `brand-emerald` | `#39B982` | positive indicators |
| `brand-snow` | `#F5F2F7` | page background |
| `brand-smoke` | `#E5DCE8` | borders, subtle dividers |
NEVER use raw hex values in HTML — always use the token class names above.
---
## 🎯 Goal
- Deliver an **SEO-optimised, fast-loading, responsive** single-page site.
- Keep all edits **minimal and targeted** — preserve design, structure, and functionality.
- NEVER introduce new frameworks, libraries, or external dependencies.
### Quality bar for every change
| Concern | Rule |
|---|---|
| SEO | Keep all existing meta tags, `ld+json`, `alt` attributes, and heading hierarchy |
| Performance | No comments in production HTML, no unused CSS classes, no render-blocking additions |
| Responsive | Every element must work on mobile (375 px), tablet (768 px), and desktop (1280 px+) |
| Consistency | Use only brand token classes; match existing spacing and rounding patterns |
---
## Commands
```bash
# Build CSS after editing src/input.css or tailwind.config.js
npm run build:css
# Watch CSS during active development
npm run watch:css
# Validate blog.json
jq empty blog.json && echo "JSON valid"
# Start dev server
./start
```
---
## Git Rules
- ALWAYS work on **`main`** branch.
- Pull before every session:
```bash
git pull origin main
```
- Commit messages must follow the pattern:
- `blog: add post <title>`
- `fix: <short description>`
- `content: <short description>`
- Push immediately after committing — never leave unpushed commits.
- Branch creation is permitted ONLY under the **Branch Handling Exception** (see bottom).
---
## File Safety Rules
- ONLY modify files required for the task.
- NEVER delete or restructure content unrelated to the task.
- NEVER remove existing blog posts from `blog.json`.
- NEVER edit `assets/site.css` directly — always rebuild from `src/input.css`.
---
## Blog Post Schema
### Language-to-file mapping
| Language | File loaded | Fallback |
|---|---|---|
| English (`en`) | `blog.json` | — |
| Spanish (`es`) | `blog.es.json` | `blog.json` |
| German (`de`) | `blog.de.json` | `blog.json` |
The loader in `index.html` fetches the language-specific file automatically when the visitor switches language. If a language file is missing or returns an error, it falls back to `blog.json` silently.
`blog.json` is always the **English source and fallback**. Keep all three files structurally identical — same number of posts, same order, translated content only.
---
`blog.json` (and every language variant) is a root-level JSON array. Every post object contains **exactly these five fields**:
```json
[
{
"area": "Automation",
"date": "2026-03-25",
"title": "Article title as plain text",
"teaser": "<p>One or two sentences visible before 'Read more'.</p>",
"content": "<h3>Section</h3><p>Full article as HTML.</p>"
}
]
```
| Field | Type | Constraint |
|---|---|---|
| `area` | plain string | Category label rendered above the title |
| `date` | ISO 8601 (`YYYY-MM-DD`) | Used by `Intl.DateTimeFormat` |
| `title` | plain string | Rendered as `<h3>` — no HTML |
| `teaser` | HTML string | Always visible — wrap in `<p>` tags |
| `content` | HTML string | Revealed by "Read more" toggle — use `<h3>`, `<p>`, `<ul>`, `<li>` |
NEVER add extra fields (`slug`, `author`, `tags`, `excerpt`, `hero_image`, etc.) — they are ignored by the renderer and pollute the file.
### Adding a new blog post (jq method)
> Add to **all three files** in the same session. A post that exists in English but not in Spanish/German will silently fall back to the English version for those visitors — which is acceptable temporarily but should be resolved promptly.
```bash
# 1. Pull latest
git pull origin main
# 2. Backup all blog files once per session
mkdir -p /backup
for f in blog.json blog.es.json blog.de.json; do
cp $f /backup/$f.$(date +%Y%m%d%H%M%S)
done
# 3. Prepend new post to each language file
# $NEW_EN, $NEW_ES, $NEW_DE must each be a valid single post object
jq --argjson new "$NEW_EN" '. = [$new] + .' blog.json > tmp.json && mv tmp.json blog.json
jq --argjson new "$NEW_ES" '. = [$new] + .' blog.es.json > tmp.json && mv tmp.json blog.es.json
jq --argjson new "$NEW_DE" '. = [$new] + .' blog.de.json > tmp.json && mv tmp.json blog.de.json
# 4. Validate all three
jq empty blog.json && echo "blog.json OK"
jq empty blog.es.json && echo "blog.es.json OK"
jq empty blog.de.json && echo "blog.de.json OK"
# 5. Commit and push
git add blog.json blog.es.json blog.de.json
git commit -m "blog: add post <title>"
git push origin main
```
NEVER use Python, Node scripts, or manual text editing to modify `blog.json`.
---
## Execution Workflow
Follow this order for every task:
1. `git pull origin main`
2. Identify the exact files to change — nothing else.
3. Apply minimal edits.
4. If `src/input.css` or `tailwind.config.js` were changed → rebuild CSS.
5. Validate: `jq empty blog.json` for JSON; visual check for HTML.
6. Commit with a descriptive message.
7. `git push origin main`
---
## Success Criteria
- Changes are minimal, validated, and committed.
- `blog.json` is a valid array with exactly five fields per post.
- `assets/site.css` reflects any CSS changes (rebuilt, not hand-edited).
- HTML passes a visual check — layout and design unchanged except for the intended edit.
- All commits pushed to `main`.
---
## Failure Conditions
These actions constitute a failure:
- Refusing to act due to perceived access restrictions.
- Failing to push after committing.
- Adding undeclared fields to `blog.json`.
- Removing or overwriting existing blog posts.
- Editing `assets/site.css` by hand.
- Modifying files outside the scope of the task.
- Leaving a branch open without merging.
---
## Branch Handling Exception
Branch creation is permitted **only** when:
- The task explicitly requires a feature branch, **or**
- A direct push to `main` is blocked by branch protection.
If a branch is created it **must**:
- Follow the naming convention `<type>/<short-description>` (e.g. `fix/blog-json-schema`).
- Be merged into `main` via PR immediately after the task is complete.
- Be deleted after merge — never abandoned.
@@ -0,0 +1,26 @@
Rollback and Recovery Changelog
Timestamp: ${TS}
Actions performed:
- Reset main branch (working tree and HEAD) to commit 6127867d51c9f0e4e86d1d8390081208f45136ab and force-pushed to origin/main.
- Confirmed README.md content matches version at commit 6127867d51.
- Investigated deleted backups: located backups in parent commit 5189b23 and extracted available files into recovered_backups/ directory in the working tree (not yet committed).
- Created this changelog and saved recovered backup files under recovered_backups/ for review.
Recovered backup files:
- backups/index.html.20260324164129.bak -> recovered_backups/index.html.20260324164129.bak (extracted from commit 5189b23)
- backups/index.html.bak.20260324164053 -> recovered_backups/index.html.bak.20260324164053 (extracted from commit 5189b23)
Notes on recovery:
- The deleted backup files were present in previous commits and have been extracted from git history into recovered_backups/.
- Additional backup files referencing other timestamps exist in git history (e.g., backups/index.html.20260324164556.bak and backups/index.html.20260324164626.bak). These can be extracted on request.
Suspicious findings:
- Commit 6127867d51 (message: "Deleted Backups and changed readme") removed backup files. Author: Oliver. This commit may be responsible for missing backups. Please confirm whether this deletion was authorized.
Remaining tasks / recommendations:
- Decide whether to reintroduce recovered backups into main branch. Restoring them to main will create a new commit on top of 6127867d51 (this changelog can be used), or they can be provided in a separate branch or as artifacts.
- If you want me to commit the recovered_backups/ content and this changelog to the repository and push, confirm and I will proceed.
- If you prefer the repository to remain exactly at 6127867d51 (no additional commits), I will not commit recovered files; instead I will provide them separately (download/attach) and leave main at the rollback state.
+26 -16
View File
@@ -5,24 +5,12 @@
**Stack:** Static HTML · Tailwind CSS · Vanilla JS
---
## BLOG POSTS
Blog posts live in `blog.json` — do not create separate backup files.
To add a blog post customize this command
jq '.arrays[0] = [$NEW_JSON] + .arrays[0]' blog.json > tmp.json && mv tmp.json blog.json
## Repository Rules
- Do not touch this readme
- Keep the seperation of Site and the blogposts in blog.json
Example of JSON structure for blog.json
{
"area": "Transparency",
"title": "Every Peso on the Page: Ana's Ledger Story",
"teaser": "<p>Ana replaced five notebooks with a four-stop impact ledger and now answers donor questions in seconds.</p>",
"content": "<h3>Every peso needs a map</h3>\n<p>Ana used to juggle five notebooks and still felt blind when a donor asked where their coin landed.</p>\n<h3>Pressure points</h3>\n<p without late nights.</p>\n",
"date": "2026-03-22"
},
- Blog posts live in `blog.json` — do not create separate backup files.
- No backup files in the repo.
- Update this README with every commit.
---
@@ -38,6 +26,22 @@ Example of JSON structure for blog.json
---
## CI Checks (required on every push)
- **HTML lint** — valid markup, no broken anchors
- **JS lint** — no errors, no `console.error` left in production paths
- **Tailwind build** — `npx tailwindcss -i ./src/input.css -o ./assets/site.css --minify` must exit 0
- **Corporate design compliance:**
- No CSS gradients — `gradient`, `bg-*-gradient`, `linear-gradient`, `radial-gradient` are forbidden
- No CSS animations or keyframes — `animate-*`, `@keyframes`, transform-based `hover:translate-*` are forbidden
- No blur or glassmorphism effects — `backdrop-blur`, `backdrop-filter` are forbidden
- Colors must use the approved brand palette only: `brand-ink · brand-primary · brand-accent · brand-sunrise · brand-emerald · brand-snow · brand-smoke`
- Images must be served from `odoo4projects.com`, `images.unsplash.com`, or local `assets/`
- **Accessibility** — heading hierarchy (one `<h1>` per page), `alt` on all images, sufficient contrast (WCAG AA)
- **Performance budget** — total page weight ≤ 200 KB (excluding cached fonts); critical render path ≤ 1 s on fast 3G
- **SEO** — `<title>`, `<meta name="description">`, `<h1>`, canonical `<link>`, and JSON-LD structured data must be present
---
## Languages
@@ -48,3 +52,9 @@ Example of JSON structure for blog.json
| `de` | Deutsch |
All UI strings are managed via the inline `translations` object in `index.html`.
---
## Focus Keywords
`NGO donor management` · `transparent donations` · `Odoo for NGOs` · `donor communication` · `minimal admin for charities`
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
-16
View File
@@ -1,16 +0,0 @@
[
{
"area": "Automatisierung",
"date": "2026-03-25",
"title": "NGO-Verwaltung um 2040 % reduzieren: 7 Workflows, die du diesen Monat automatisieren kannst",
"teaser": "<p>Maria verbrachte ihre Abende mit Papierkram statt mit den Familien, denen sie helfen wollte. Mit Odoo und n8n reduzierte ihr Team den Verwaltungsaufwand in unter vier Wochen um ein Drittel.</p>",
"content": "<h3>Die Admin-Falle, die jede NGO kennt</h3><p>Verstreute Spenderdaten, wiederholte Kopier-Einfüge-Schritte und langsame Bestätigungen untergraben das Vertrauen und kosten wertvolle Energie. Ein ERP bündelt Kontakte, Spenden, Rechnungen und Projektaufgaben an einem zentralen Ort — niemand muss dieselben Daten mehr in fünf verschiedene Tools eintippen.</p><h3>Odoo als Kern, n8n als Verbindung</h3><p>Nutze Odoo für Kontakte, Spenden, Projekte und Dokumente. Erstelle dann n8n-Workflows, um automatische Spendenquittungen zu versenden, Kontaktdatensätze bei Formulareinreichungen zu aktualisieren, Folgeaufgaben zu terminieren und Zusammenfassungsberichte per Slack oder E-Mail zu verteilen.</p><h3>7 Workflows für diesen Monat</h3><ul><li>Sofortige Dankesnachricht an Spender bei jeder Zuwendung</li><li>Fördermittel-Compliance-Erinnerungen im 30-Tage-Rhythmus</li><li>Onboarding-Checkliste für Freiwillige automatisch bei der Anmeldung</li><li>Monatlicher Impact-Bericht automatisch erstellt und versendet</li><li>Dubletten-Erkennung bei jedem Datenimport</li><li>Rechnungsgenehmigung nach Projekt und Budgetposition geroutet</li><li>Ausgabenerfassung aus dem Feld mit Foto-Belegerfassung</li></ul><h3>Was du erwarten kannst</h3><p>Typische NGO-Implementierungen zeigen innerhalb weniger Wochen 2040 % weniger Verwaltungsstunden. Marias Team hörte mit den nächtlichen Admin-Sprints auf und begann jeden Morgen mit einem einzigen Dashboard — mehr Zeit für Lehrercoaching, Gemeindebesuche und die Planung wirkungsvoller Programme.</p>"
},
{
"area": "Spenderkommunikation",
"date": "2026-03-18",
"title": "Wie Maya das Spender-Chaos in klare Verbindungen verwandelte",
"teaser": "<p>Maya verwandelte verstreute Spendernachrichten in einen reibungslosen, vertrauenswürdigen Ablauf, der die Unterstützung wachsen lässt und das gesamte Team auf Kurs hält.</p>",
"content": "<h3>Von verstreuten E-Mails zur einheitlichen Ansicht</h3><p>Maya jonglierte früher mit Tabellen, E-Mail-Threads und Haftnotizen, um Spenderinteraktionen zu verfolgen. Nachrichten gingen verloren, Dankesworte kamen verspätet an und das Vertrauen bröckelte. Sie wusste, dass sich etwas ändern musste.</p><h3>Den Spender-Journey strukturieren</h3><p>Sie kartierte jeden Kontaktpunkt: Erstkontakt, Spende, Nachfassaktionen, Erneuerungen und Sonderkampagnen. Jede Interaktion erhielt eine Karte in Odoo mit Spendername, Datum, bevorzugtem Kanal und Teamnotizen — für alle auf einen Blick sichtbar.</p><h3>Automatisierte Berührungspunkte</h3><p>n8n übernimmt die Routine: Spender werden innerhalb von Minuten nach einer Zuwendung gedankt, Zusagen werden bestätigt und das Team wird vor fälligen Erneuerungen benachrichtigt. Nichts fällt durchs Raster, und der persönliche Ton bleibt erhalten — dank Vorlagen, die auf jedes Spendersegment zugeschnitten sind.</p><h3>Transparenz auf allen Ebenen</h3><p>Spender können Updates zu den Projekten sehen, die sie unterstützt haben. Status-Labels in Kampagnen zeigen zugewiesene Mittel, Impact-Geschichten und bevorstehende Veranstaltungen. Mitarbeiter hinterlassen für Kollegen sichtbare Notizen — doppelte Kontaktaufnahmen und unangenehme Überschneidungen gehören der Vergangenheit an.</p><h3>Visuelle Echtzeit-Dashboards</h3><p>Ein Live-Dashboard zeigt Spenderaktivitäten, bevorstehende Erneuerungen und die Kampagnengesundheit auf einen Blick. Farbcodes setzen Prioritäten: Grün für bestätigte Gaben, Gelb für ausstehende Zusagen, Rot für dringende Nachfassaktionen, die menschliche Aufmerksamkeit erfordern.</p><h3>Kommunikationsrhythmus</h3><p>Wöchentliche Spender-Check-ins halten das Team auf Kurs. Monatliche Newsletter heben Erfolge, Spender-Impact und kommende Möglichkeiten hervor. Sonderkampagnen lösen personalisierte Micro-E-Mails aus, die auf Spenderlevel und Verlauf jeder Person abgestimmt sind.</p><ul><li>Montag: neue Spender und ausstehende Nachfassaktionen prüfen.</li><li>Mittwoch: Kampagnenfortschritt bestätigen und Spendernotizen aktualisieren.</li><li>Freitag: Dankesmails und Impact-Updates versenden.</li></ul><h3>Kennzahlen &amp; Wirkung</h3><p>Die Spenderbindung verbesserte sich um 25 %. Reaktionszeiten sanken von Tagen auf Stunden. Mitarbeiter berichten von weniger Stress, weil Kommunikation berechenbar, sichtbar und nicht mehr vom Gedächtnis einer einzelnen Person abhängig ist.</p><h3>Dein Rollout-Plan</h3><ul><li>Alle Spenderdaten in Odoo-Karten importieren und Verantwortliche zuweisen.</li><li>n8n-Alerts für Gaben, Nachfassaktionen und bevorstehende Erneuerungen einrichten.</li><li>Dashboards mit aktiven Spendern, Zusagen und Kampagnenstatus erstellen.</li><li>Dankesmails und vierteljährliche Impact-Updates automatisieren.</li></ul><p>Vier Schritte ersetzen wochenlange verstreute E-Mails und verpasste Möglichkeiten — und jeder Spender beginnt sich wirklich wahrgenommen zu fühlen.</p>"
}
]
-16
View File
@@ -1,16 +0,0 @@
[
{
"area": "Automatización",
"date": "2026-03-25",
"title": "Reduce el trabajo administrativo de tu ONG un 2040 %: 7 flujos que puedes automatizar este mes",
"teaser": "<p>María pasaba sus noches sepultada en papeleo en lugar de ayudar a las familias que había venido a servir. Con Odoo y n8n, su equipo redujo el tiempo administrativo en un tercio en menos de cuatro semanas.</p>",
"content": "<h3>La trampa administrativa que toda ONG conoce</h3><p>Datos dispersos de donantes, pasos repetitivos de copiar y pegar y acuses de recibo lentos erosionan la confianza y agotan la energía del equipo. Un ERP centraliza contactos, donaciones, facturas y tareas de proyecto en un único lugar, de modo que nadie tenga que reintroducir los mismos datos en cinco herramientas distintas.</p><h3>Odoo como núcleo, n8n como conector</h3><p>Usa Odoo para Contactos, Donaciones, Proyectos y Documentos. Después crea flujos en n8n para enviar recibos automáticos a donantes, actualizar registros cuando se envían formularios, programar tareas de seguimiento y distribuir informes de resumen por Slack o correo electrónico.</p><h3>7 flujos para automatizar este mes</h3><ul><li>Correo de agradecimiento instantáneo al donante en cada aportación</li><li>Recordatorios de cumplimiento de subvenciones cada 30 días</li><li>Lista de incorporación de voluntarios activada al registrarse</li><li>Informe de impacto mensual generado y enviado automáticamente</li><li>Detección de contactos duplicados en cada importación</li><li>Enrutamiento de aprobaciones de facturas por proyecto y línea presupuestaria</li><li>Envío de gastos desde campo con captura de recibo fotográfico</li></ul><h3>Resultados esperados</h3><p>Las implementaciones habituales en ONG muestran un 2040 % menos de horas administrativas en pocas semanas. El equipo de María dejó de hacer maratones nocturnos de administración y empezó cada mañana con un solo panel de control, liberando tiempo para capacitar a docentes, visitar comunidades y planificar programas de impacto.</p>"
},
{
"area": "Comunicación con Donantes",
"date": "2026-03-18",
"title": "Cómo Maya convirtió el caos de donantes en conexiones claras",
"teaser": "<p>Maya transformó los mensajes dispersos de donantes en un flujo fluido y confiable que hace crecer el apoyo y mantiene a todo el equipo alineado.</p>",
"content": "<h3>De correos dispersos a una visión única</h3><p>Maya solía manejar hojas de cálculo, hilos de correo y notas adhesivas para rastrear las interacciones con donantes. Se perdían mensajes, los agradecimientos llegaban tarde y la confianza comenzaba a tambalearse. Sabía que algo tenía que cambiar.</p><h3>Organizando el recorrido del donante</h3><p>Mapeó cada punto de contacto: contacto inicial, donación, seguimientos, renovaciones y campañas especiales. Cada interacción tuvo su tarjeta en Odoo con el nombre del donante, la fecha, el canal preferido y las notas del equipo, visible para todos de un vistazo.</p><h3>Puntos de contacto automatizados</h3><p>n8n automatiza la rutina: agradece a los donantes a los pocos minutos de recibir una aportación, confirma los compromisos y avisa al equipo antes de que venzan las renovaciones. Nadie se queda sin respuesta y el tono personal se mantiene intacto gracias a plantillas diseñadas para cada segmento de donantes.</p><h3>Transparencia en todos los niveles</h3><p>Los donantes pueden ver actualizaciones de los proyectos que apoyaron. Las etiquetas de estado en las campañas muestran los fondos asignados, historias de impacto y próximos eventos. El personal deja notas visibles para sus colegas, eliminando contactos duplicados y superposiciones incómodas.</p><h3>Paneles visuales en tiempo real</h3><p>Un panel en vivo muestra la actividad de donantes, las próximas renovaciones y la salud de la campaña de un solo vistazo. Los códigos de color marcan la prioridad: verde para donaciones confirmadas, amarillo para compromisos pendientes, rojo para seguimientos urgentes que requieren atención humana.</p><h3>Ritmo de comunicación</h3><p>Las revisiones semanales de donantes mantienen al equipo alineado. Los boletines mensuales destacan logros, impacto de los donantes y próximas oportunidades. Las campañas especiales activan microemails personalizados según el nivel de donación y el historial de cada persona.</p><ul><li>Lunes: revisar nuevos donantes y seguimientos pendientes.</li><li>Miércoles: confirmar el avance de las campañas y actualizar las notas de donantes.</li><li>Viernes: enviar correos de agradecimiento y actualizaciones de impacto.</li></ul><h3>Métricas e impacto</h3><p>La retención de donantes mejoró un 25 %. Los tiempos de respuesta bajaron de días a horas. El personal reporta menos estrés porque la comunicación es predecible, visible y ya no depende de la memoria de una sola persona.</p><h3>Tu plan de implementación</h3><ul><li>Importar todos los registros de donantes a Odoo y asignar responsables.</li><li>Configurar alertas en n8n para donaciones, seguimientos y renovaciones.</li><li>Crear paneles con donantes activos, compromisos y estado de campaña.</li><li>Automatizar correos de agradecimiento y actualizaciones de impacto trimestrales.</li></ul><p>Cuatro pasos reemplazan semanas de correos dispersos y oportunidades perdidas — y cada donante empieza a sentirse genuinamente valorado.</p>"
}
]
+36 -9
View File
File diff suppressed because one or more lines are too long
-677
View File
@@ -1,677 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Dashboard</title>
<style>
body {
margin: 0;
width: 1920px;
height: 1200px;
font-family: Arial, sans-serif;
background: #2f3e4e;
color: #e6edf3;
overflow: hidden;
}
.dashboard {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
display: grid;
grid-template-columns: 2fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 20px;
}
.box {
background: #3b4c5e;
border-radius: 14px;
padding: 18px;
box-sizing: border-box;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.25);
position: relative;
overflow: hidden;
}
.title {
font-size: 20px;
font-weight: bold;
margin-bottom: 12px;
opacity: 0.9;
}
.status-light {
position: absolute;
top: 18px;
right: 18px;
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.green {
background: #3ad29f;
}
.yellow {
background: #f5c542;
}
.red {
background: #e05d5d;
}
.green-text {
color: #3ad29f;
}
.yellow-text {
color: #f5c542;
}
.red-text {
color: #e05d5d;
}
/* SERVER GRID */
.servers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.server {
background: #44586d;
border-radius: 10px;
padding: 12px;
position: relative;
}
.server-name {
font-size: 15px;
margin-bottom: 8px;
opacity: 0.85;
font-weight: bold;
padding-right: 22px;
}
.server .status-light {
top: 12px;
right: 12px;
}
.metrics {
font-size: 12px;
line-height: 1.7;
opacity: 0.85;
}
.metric-row {
display: flex;
justify-content: space-between;
align-items: center;
}
/* MEETINGS */
.meetings-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.meeting {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
border: 2px solid transparent;
position: relative;
}
.meeting.soon {
border: 2px solid #e05d5d;
box-shadow: 0 0 8px rgba(224, 93, 93, 0.4);
}
.meeting-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.meeting-name {
font-size: 13px;
font-weight: bold;
opacity: 0.95;
flex: 1;
}
.meeting-badges {
display: flex;
gap: 4px;
margin-left: 6px;
}
.badge {
font-size: 9px;
padding: 2px 5px;
border-radius: 4px;
font-weight: bold;
white-space: nowrap;
}
.badge-odoo {
background: #714b67;
color: #e6edf3;
}
.badge-brandize {
background: #1a6fa0;
color: #e6edf3;
}
.time {
font-size: 11px;
opacity: 0.65;
margin-top: 3px;
}
/* TODOS */
.todos-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.todo {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 13px;
opacity: 0.9;
display: flex;
align-items: center;
gap: 8px;
}
.todo.done-todo {
opacity: 0.5;
text-decoration: line-through;
}
.todo-check {
width: 14px;
height: 14px;
border-radius: 3px;
border: 2px solid #8aa0b5;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.todo-check.checked {
background: #3ad29f;
border-color: #3ad29f;
}
/* LATEST SIGNUPS */
.signups-scroll {
overflow-y: auto;
max-height: calc(100% - 45px);
}
.signup-item {
background: #44586d;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
font-size: 12px;
}
.signup-email {
font-size: 13px;
font-weight: bold;
margin-bottom: 4px;
opacity: 0.95;
}
.signup-meta {
display: flex;
gap: 8px;
opacity: 0.7;
flex-wrap: wrap;
}
.signup-tag {
background: #2f3e4e;
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
color: #3ad29f;
}
.signup-server {
font-size: 11px;
opacity: 0.7;
}
/* CURRENT TESTS */
.test-box {
background: #44586d;
border-radius: 10px;
padding: 12px;
font-size: 12px;
line-height: 1.8;
}
.test-row {
display: flex;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 3px 0;
}
.test-label {
opacity: 0.6;
font-size: 11px;
}
.test-value {
font-size: 11px;
text-align: right;
max-width: 65%;
word-break: break-all;
}
.status-badge {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: bold;
background: #3ad29f33;
color: #3ad29f;
}
.status-badge.dev {
background: #f5c54233;
color: #f5c542;
}
/* LOADING / ERROR */
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 18px;
opacity: 0.5;
letter-spacing: 1px;
}
.error-msg {
color: #e05d5d;
font-size: 13px;
opacity: 0.85;
padding: 12px;
}
</style>
</head>
<body>
<div class="dashboard" id="dashboard">
<div class="loading" style="grid-column: 1/-1; grid-row: 1/-1">
Loading dashboard…
</div>
</div>
<script>
const WEBHOOK_URL =
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/52781c84-9f12-4a61-96b2-9e1e3933148d";
const REFRESH_INTERVAL_MS = 60 * 1000; // refresh every 60 seconds
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mbToGb(mb) {
return (mb / 1024).toFixed(1);
}
function pct(free, total) {
return total > 0 ? (free / total) * 100 : 0;
}
function metricColor(freePct) {
if (freePct < 10) return "red";
if (freePct < 20) return "yellow";
return "green";
}
function worstColor(colors) {
if (colors.includes("red")) return "red";
if (colors.includes("yellow")) return "yellow";
return "green";
}
// Parse the wall-clock date/time directly from an ISO-8601 string that
// already carries an offset (e.g. "2026-03-31T10:00:00-03:00").
// We never let the browser re-interpret it into its own timezone.
function parseISOParts(dateTimeStr) {
// Matches: YYYY-MM-DDTHH:MM:SS(±HH:MM or Z)
const m = dateTimeStr.match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/,
);
if (!m) return null;
return {
year: parseInt(m[1], 10),
month: parseInt(m[2], 10),
day: parseInt(m[3], 10),
hour: parseInt(m[4], 10),
minute: parseInt(m[5], 10),
};
}
function formatLocalTime(dateTimeStr) {
const p = parseISOParts(dateTimeStr);
if (!p) return "";
const h = String(p.hour).padStart(2, "0");
const m = String(p.minute).padStart(2, "0");
return `${h}:${m}`;
}
function formatLocalDate(dateTimeStr) {
const p = parseISOParts(dateTimeStr);
if (!p) return "";
// Build a UTC date just for the locale label so day/month names are correct
const d = new Date(Date.UTC(p.year, p.month - 1, p.day));
return d.toLocaleDateString([], {
weekday: "short",
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
function isToday(dateTimeStr) {
const now = new Date();
const p = parseISOParts(dateTimeStr);
if (!p) return false;
return (
p.year === now.getFullYear() &&
p.month === now.getMonth() + 1 &&
p.day === now.getDate()
);
}
function isSoonOrOngoing(startStr, endStr) {
const now = new Date();
const start = new Date(startStr);
const end = new Date(endStr);
const diffMin = (start - now) / 60000;
if (now >= start && now <= end) return true;
if (diffMin >= 0 && diffMin <= 30) return true;
return false;
}
// ---------------------------------------------------------------------------
// Server cards
// ---------------------------------------------------------------------------
function buildServerCard(s) {
const ramPct = pct(s.free_ram, s.available_ram);
const hddPct = pct(s.free_hdd, s.available_hdd);
const ramColor = metricColor(ramPct);
const hddColor = metricColor(hddPct);
const lightColor = worstColor([ramColor, hddColor]);
const displayNames = {
saopaulo: "São Paulo",
manchester: "Manchester",
mumbai: "Mumbai",
sydney: "Sydney",
meppel: "Meppel",
boston: "Boston",
};
const displayName =
displayNames[s.Server] ||
s.Server.charAt(0).toUpperCase() + s.Server.slice(1);
return {
lightColor,
html: `
<div class="server">
<div class="server-name">${displayName}</div>
<div class="status-light ${lightColor}"></div>
<div class="metrics">
<div class="metric-row">
<span>RAM:</span>
<span class="${ramColor}-text">${ramPct.toFixed(0)}% free (${mbToGb(s.free_ram)} GB)</span>
</div>
<div class="metric-row">
<span>HDD:</span>
<span class="${hddColor}-text">${hddPct.toFixed(0)}% free (${mbToGb(s.free_hdd)} GB)</span>
</div>
</div>
</div>
`,
};
}
// ---------------------------------------------------------------------------
// Meetings
// ---------------------------------------------------------------------------
function parseMeetings(calEvents, calName) {
const seen = new Set();
const result = [];
for (const e of calEvents) {
const ev = e.json || e;
if (seen.has(ev.id)) continue;
seen.add(ev.id);
result.push({ ...ev, _calName: calName });
}
return result;
}
function buildMeetingCard(m) {
const startStr =
(m.start && (m.start.dateTime || m.start.date)) || "";
const endStr = (m.end && (m.end.dateTime || m.end.date)) || "";
const soon =
startStr && endStr
? isSoonOrOngoing(startStr, endStr)
: false;
const today = startStr ? isToday(startStr) : false;
const badges = (m._cals || [])
.map((c) => {
if (c === "ODOO4projects")
return `<span class="badge badge-odoo">ODOO4</span>`;
if (c === "Brandize")
return `<span class="badge badge-brandize">Brandize</span>`;
return "";
})
.join("");
const dateLabel =
today || !startStr
? ""
: `<span style="font-size:10px;opacity:0.55;margin-right:4px;">${formatLocalDate(startStr)}</span>`;
const timeLabel =
startStr && endStr
? `${formatLocalTime(startStr)} ${formatLocalTime(endStr)}`
: "";
return `
<div class="meeting${soon ? " soon" : ""}">
<div class="meeting-header">
<div class="meeting-name">${m.summary || "Meeting"}</div>
<div class="meeting-badges">${badges}</div>
</div>
<div class="time">${dateLabel}${timeLabel}</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Todos
// ---------------------------------------------------------------------------
function buildTodoItem(t) {
const done = !!t.done;
const checkInner = done
? `<svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>`
: "";
return `
<div class="todo${done ? " done-todo" : ""}">
<div class="todo-check${done ? " checked" : ""}">${checkInner}</div>
<span>${t.Todotodo || ""}</span>
</div>
`;
}
// ---------------------------------------------------------------------------
// Latest Signup item
// ---------------------------------------------------------------------------
function buildSignupItem(ct) {
if (!ct) return "";
const tagBadges = (ct.tags || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => `<span class="signup-tag">${t}</span>`)
.join("");
return `
<div class="signup-item">
<div class="signup-email">${ct.email || ""}</div>
<div class="signup-meta">
<span class="signup-server">&#128421; ${ct.server || ""}</span>
${tagBadges}
<span style="opacity:0.5;font-size:10px;">${ct.status || ""}</span>
</div>
<div style="margin-top:4px;opacity:0.55;font-size:10px;">
${ct.Domains ? ct.Domains : ""}${ct.expires ? " &middot; expires " + ct.expires : ""}
</div>
</div>
`;
}
function buildSignupItems(raw) {
if (!raw) return { html: "", count: 0 };
const items = Array.isArray(raw) ? raw : [raw];
const html = items.map(buildSignupItem).join("");
return { html, count: items.length };
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function render(data) {
const serverStatusRaw = data["Server Status"] || [];
const calOdoo = data["Calender ODOO4projects"] || [];
const calBrandize = data["Calender Brandize"] || [];
const todos = data["Todos"] || [];
const currentTestsRaw = data["Current Tests"] || null;
// Deduplicate servers by Server key
const serverMap = {};
for (const s of serverStatusRaw) {
if (!serverMap[s.Server]) serverMap[s.Server] = s;
}
const servers = Object.values(serverMap);
// Server cards
const serverCards = servers.map(buildServerCard);
const overallColor = worstColor(
serverCards.map((c) => c.lightColor),
);
const serverCardsHtml = serverCards.map((c) => c.html).join("");
// Meetings merge & deduplicate across both calendars
const odooMeetings = parseMeetings(calOdoo, "ODOO4projects");
const brandizeMeetings = parseMeetings(calBrandize, "Brandize");
const allMeetings = [...odooMeetings, ...brandizeMeetings];
const meetingById = {};
for (const m of allMeetings) {
if (!meetingById[m.id]) {
meetingById[m.id] = { ...m, _cals: [m._calName] };
} else if (!meetingById[m.id]._cals.includes(m._calName)) {
meetingById[m.id]._cals.push(m._calName);
}
}
const dedupedMeetings = Object.values(meetingById).sort(
(a, b) => {
const aStart =
(a.start && (a.start.dateTime || a.start.date)) ||
"";
const bStart =
(b.start && (b.start.dateTime || b.start.date)) ||
"";
return new Date(aStart) - new Date(bStart);
},
);
const meetingsHtml = dedupedMeetings
.map(buildMeetingCard)
.join("");
const todosHtml = todos.map(buildTodoItem).join("");
const signups = buildSignupItems(currentTestsRaw);
document.getElementById("dashboard").innerHTML = `
<!-- SERVER STATUS -->
<div class="box" style="grid-column:1; grid-row:1/3;">
<div class="title">Server Status</div>
<div class="status-light ${overallColor}"></div>
<div class="servers">
${serverCardsHtml}
</div>
</div>
<!-- TODAY'S MEETINGS -->
<div class="box" style="grid-column:2; grid-row:1;">
<div class="title">Meetings</div>
<div class="meetings-scroll">
${meetingsHtml || '<div style="opacity:0.4;font-size:13px;">No meetings</div>'}
</div>
</div>
<!-- LATEST SIGNUPS -->
<div class="box" style="grid-column:3; grid-row:1/3;">
<div class="title">Latest Signups <span style="font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;margin-left:6px;">${signups.count}</span></div>
<div class="signups-scroll">
${signups.html || '<div style="opacity:0.4;font-size:13px;">No signups</div>'}
</div>
</div>
<!-- TODOS -->
<div class="box" style="grid-column:2; grid-row:2;">
<div class="title">Today's Todos</div>
<div class="todos-scroll">
${todosHtml || '<div style="opacity:0.4;font-size:13px;">No todos</div>'}
</div>
</div>
`;
}
// ---------------------------------------------------------------------------
// Fetch & refresh loop
// ---------------------------------------------------------------------------
async function loadData() {
try {
const res = await fetch(WEBHOOK_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
} catch (err) {
document.getElementById("dashboard").innerHTML = `
<div class="error-msg" style="grid-column:1/-1;grid-row:1/-1;padding:40px;">
&#9888; Failed to load dashboard data: ${err.message}<br>
<span style="font-size:11px;opacity:0.6;">Retrying in ${REFRESH_INTERVAL_MS / 1000}s…</span>
</div>
`;
}
}
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

-1
View File
@@ -1 +0,0 @@
google-site-verification: google261cafffa78d8c3d.html
+392 -2507
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -537,10 +537,11 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -967,10 +968,11 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
-2
View File
@@ -4,8 +4,6 @@
"description": "",
"main": "index.js",
"scripts": {
"build:css": "tailwindcss -i src/input.css -o assets/site.css --minify",
"watch:css": "tailwindcss -i src/input.css -o assets/site.css --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
+1 -31
View File
@@ -3,7 +3,7 @@
@tailwind utilities;
body {
@apply font-brand;
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
}
#lang-selector {
@@ -15,33 +15,3 @@ body {
color: #201824;
background-color: #ffffff;
}
/* ── Blog post modal animations ───────────────────────── */
@keyframes modal-slide-in {
from {
opacity: 0;
transform: scale(0.95) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-animate-box {
animation: modal-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.modal-animate-backdrop {
animation: modal-fade-in 0.2s ease both;
}
+14 -15
View File
@@ -1,28 +1,27 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html"],
content: ['./index.html'],
theme: {
extend: {
colors: {
"brand-ink": "#201824",
"brand-primary": "#603F57",
"brand-accent": "#F762B4",
"brand-sunrise": "#F8B84A",
"brand-emerald": "#39B982",
"brand-snow": "#F5F2F7",
"brand-smoke": "#E5DCE8",
'brand-ink': '#201824',
'brand-primary': '#603F57',
'brand-accent': '#F762B4',
'brand-sunrise': '#F8B84A',
'brand-emerald': '#39B982',
'brand-snow': '#F5F2F7',
'brand-smoke': '#E5DCE8'
},
fontFamily: {
brand: ["system-ui", "-apple-system", "Segoe UI", "sans-serif"],
brand: ['Inter', 'Segoe UI', 'system-ui', 'sans-serif']
},
boxShadow: {
brand: "0 20px 60px rgba(32, 24, 36, 0.15)",
brand: '0 20px 60px rgba(32, 24, 36, 0.15)'
},
backgroundImage: {
"hero-gradient":
"radial-gradient(circle at top left, rgba(247,98,180,0.35), rgba(32,24,36,0.95))",
'hero-gradient': 'radial-gradient(circle at top left, rgba(247,98,180,0.35), rgba(32,24,36,0.95))'
}
}
},
},
},
plugins: [],
plugins: []
};