Flo Eisenmenger, Geschäftsführer der spawntree GmbH (kleine Webagentur, Hamburg). DDEV-Entwicklungsumgebung, Symfony 7.x, PHP 8.2, MariaDB 10.11.
Ein internes Timetracking-Tool – zunächst nur für mich, später als SaaS. Technisch ähnlich wie mite.de – wir orientieren uns stark am UI/UX von mite.
spawntree.testtimetracking.ddev.sitedb_spawntreedb) enthält accountübergreifende Daten: User, Account, AccountUser, Tokencentral → App\Entity\Central\* (User, Account, AccountUser, InviteToken, RegistrationToken)tenant → App\Entity\Tenant\* (Client, Project, Service, TimeEntry)TenantConnectionMiddlewareTenantRequestSubscriber (Priorität 20, vor Firewall) liest den Subdomain-Slug aus dem HostTenantContextTenantContext ist ein Request-scoped Service, der den aktiven Account hältApp\Entity\CentralUserid, email (unique), firstName, lastName, password (nullable), notetheme (VARCHAR 20, default 'standard') – Darstellungs-Theme des UsersUserInterface, PasswordAuthenticatedUserInterfacegetFullName() → "Flo Eisenmenger"Accountid, name, slug (unique, → Subdomain), trackingInterval (smallint, default 1)primaryColor (VARCHAR 7, nullable) – Hauptfarbe des Standard-Themes (Hex, z.B. #3a7bbf). Nur Superadmin kann sie setzen. Wird für alle User des Accounts angewendet.createdAt, superAdminUser (ManyToOne → User, nullable)accountUsers (OneToMany → AccountUser)getTenantDbName() → "db_" . str_replace('-', '_', slug)isSuperAdmin(User) → boolgetAdmins() → Collection aktiver Admin-AccountUserAccountUserid, account (ManyToOne), user (ManyToOne), role (admin/member/tracker), archivedAtROLE_ADMIN, ROLE_MEMBER, ROLE_TRACKERisArchived(), isAdmin(), isMember(), isTracker(), isMemberOrAdmin()getRoleLabel() → DeutschInviteTokenid, token, account, email, firstName, lastName, role, createdAtisExpired() (7 Tage)RegistrationTokenApp\Entity\TenantClient (Kunde)id, name, hourlyRate (decimal, nullable), noteProjectsProjectid, name, client (ManyToOne → Client), noteService (Leistung)id, name, billable (bool, default true), noteTimeEntryid, date (DATE_IMMUTABLE), duration (int, Minuten), userId (int, kein FK – cross-DB)project (ManyToOne), service (ManyToOne, nullable), note, invoiced (bool, default false)createdAt, updatedAt (via @PreUpdate)toArray() für JSON-Responses (inkl. projectName, clientName, serviceName, serviceBillable, invoiced)getDurationFormatted() → "1:30"GET / → app_home (HomeController – Landing Page oder Redirect)GET /register → app_register (Registrierungsseite)POST /api/register → api_registerPOST /api/register/preview-slug → api_register_preview_slug (Live-Slug-Vorschau)GET /verify/{token} → app_verify (E-Mail-Bestätigung)GET /login → app_loginGET /logout → app_logoutGET /invite/{token} → app_invite (Passwort setzen nach Einladung)GET /password-reset → Passwort-Reset anfordernGET /password-reset/{token} → Neues Passwort setzenGET / → Redirect (je nach Login-Status)GET /week → timetracking_weekGET /week/{date} → timetracking_week_dateGET /api/entries?date=Y-m-d → Einträge für einen Tag (JSON)POST /api/entries → Eintrag erstellenPATCH /api/entries/{id} → Eintrag bearbeitenDELETE /api/entries/{id} → Eintrag löschenPATCH /api/entries/{id}/invoiced → Abrechnungsstatus toggelnGET /clients → client_indexPOST /api/clients, PATCH /api/clients/{id}, DELETE /api/clients/{id}GET /projects → project_indexPOST /api/projects, PATCH /api/projects/{id}, DELETE /api/projects/{id}GET /services → service_indexPOST /api/services, PATCH /api/services/{id}, DELETE /api/services/{id}GET /reports/times → report_timesGET /team → team_indexPOST /api/team/invite → Einladung versendenPATCH /api/team/{id} → Mitglied bearbeitenPATCH /api/team/{id}/archive → Mitglied archivierenPATCH /api/team/{id}/unarchive → Mitglied reaktivierenDELETE /api/team/{id} → Mitglied entfernenDELETE /api/team/invite/{id} → Einladung löschenGET /account → account_index (Tab: account / user, je nach Rolle)PATCH /api/account → Name + trackingInterval + primaryColor (nur Admin; primaryColor nur vom Superadmin befüllt)PATCH /api/account/superadmin → Kontoinhaber übertragen (nur aktueller Superadmin)PATCH /api/account/user → Eigene Profildaten / Passwort / Theme ändernTenantContext – hält aktiven Account für den RequestTenantConnectionMiddleware – wechselt Tenant-DB-Name zur LaufzeitTenantRequestSubscriber – liest Subdomain, setzt TenantContext (Prio 20)ArchivedUserChecker – blockiert Login für archivierte UserArchivedUserSubscriber – wirft Exception bei archivierten Usern während des RequestsSlidingSessionSubscriber – verlängert Session bei AktivitätAccountRoleHelper – isAdmin(), isMember(), isTracker() für den aktuellen User/AccountRegistrationService – startRegistration(), confirm(token) – erstellt Account + DB + UserSlugGenerator – generiert/prüft Slugs aus FirmennamenBrandColorService – leitet aus einem Hex-Farbwert (primaryColor) ein komplettes 6-Farben-Palette-Array via HSL-Offsets ab; wird in AppExtension und AccountController genutztAppExtension – Twig-Funktionen: deMonths(), deMonthsShort(), deWeekdays(), deWeekdaysShort(), isCurrentUserAdmin(), isCurrentUserMemberOrAdmin(), getCurrentUserRole(), brandPalette() (gibt das berechnete Farbpaletten-Array zurück, oder null wenn Standardfarbe)AppExtensionRuntime – Runtime-Teil der Twig-ExtensionAccessDeniedHandler – leitet bei 403 auf Login umJeder User kann sein persönliches Theme wählen (gespeichert in User.theme):
standard – Volle Navigation, alle Felder sichtbar (Default)minimal – Ablenkungsfreie Ansicht: keine Top-Nav, Hamburger-Menü, Wochenansicht einklappbar, borderlose Eingabefelder, Entry-Liste per Klick aufklappbarbody[data-theme="minimal"] wird in base.html.twig gesetzt.
Der Superadmin kann in den Account-Einstellungen eine Hauptfarbe (Hex, z.B. #3a7bbf) hinterlegen. Diese gilt für alle User des Accounts im Standard-Theme.
Funktionsweise:
Account.primaryColor wird in der DB gespeichertBrandColorService::compute($hex) leitet daraus 6 Farbvarianten ab (HSL-Offsets: primary +9L, primaryDark -3L, primaryLight +20L/-5S, headerFrom +13L/-4S, headerTo = Basisfarbe, bg +40L/-30S)AppExtension::brandPalette() gibt die Palette zurück (oder null bei Standardfarbe #3a7bbf)base.html.twig injiziert ein <style>:root { --color-xxx: ... }</style> Block wenn eine Custom-Farbe gesetzt istvar(--color-xxx) für alle sichtbaren Farbverwendungen (Gradienten, Texte, Borders, Backgrounds). Subtile rgba($color-primary, 0.02–0.1) Tints bleiben compilierte SCSS-Werte (praktisch unsichtbar)CSS Custom Properties (:root Defaults in _variables.scss):
--color-primary: #4a90d9--color-primary-dark: #3178b8--color-primary-light: #6aaee8--color-header-from: #5b9fd6--color-header-to: #3a7bbf--color-bg: #dce9f5--color-primary-rgb: 74, 144, 217templates/
├── base.html.twig ← Brand-Farbe CSS-Injection hier
├── _sections/
│ ├── nav.html.twig ← Top-Nav + Hamburger-Nav
│ ├── tt-header.html.twig ← Minimal-Bar + Wochennavigation (collapsible)
│ └── _atoms/ ← Icons, duration-help, etc.
├── home/
│ └── index.html.twig ← Landing Page (Hauptdomain ohne Subdomain)
├── security/
│ └── login.html.twig
├── registration/
│ ├── register.html.twig
│ ├── confirmed.html.twig ← Nach E-Mail-Bestätigung
│ └── confirm_error.html.twig
├── invite/
│ ├── set_password.html.twig ← Passwort setzen nach Einladung
│ └── error.html.twig
├── email/
│ ├── team_invite.html.twig
│ ├── registration_welcome.html.twig
│ ├── registration_confirm.html.twig
│ └── registration_notify.html.twig
├── timetracking/
│ ├── week.html.twig ← Hauptseite Zeiterfassung
│ └── _entry_row.html.twig
├── client/
│ └── index.html.twig
├── project/
│ └── index.html.twig
├── service/
│ └── index.html.twig
├── team/
│ └── index.html.twig ← Team-Verwaltung (nur Admins)
├── account/
│ └── index.html.twig ← Account- + Profil-Einstellungen (Tabs)
└── report/
└── times.html.twig ← Zeiteinträge-Report
assets/styles/
├── main.scss ← Entry Point
├── atoms/
│ ├── _variables.scss ← SCSS-Vars + :root CSS Custom Properties
│ ├── _typography.scss
│ ├── _buttons.scss ← .btn, .btn-primary, .btn-secondary
│ └── _inputs.scss ← .input, .select, .textarea
├── components/
│ ├── _week-nav.scss
│ ├── _month-calendar.scss
│ ├── _entry-form.scss
│ ├── _entry-list.scss
│ ├── _duration-help.scss
│ ├── _main-nav.scss
│ ├── _greeting.scss
│ ├── _crud.scss ← Kunden/Projekte/Leistungen
│ ├── _login.scss
│ ├── _register.scss
│ ├── _team.scss
│ ├── _account.scss
│ └── _report.scss
├── sections/
│ ├── _timetracking.scss ← .tt-page, .tt-header, .tt-content
│ ├── _home.scss ← Landing Page
│ └── _report.scss
└── themes/
└── _minimal.scss ← body[data-theme="minimal"] Overrides
assets/
├── app.js ← Entry: importiert main.scss, calendar.js, entries.js
└── scripts/
├── calendar.js ← WeekCalendar (Wochennavigation, Monatsansicht)
│ Positioniert Monatskalender relativ zum Cal-Icon
├── entries.js ← EntryManager (CRUD, fetch, localStorage)
│ + initMinimalMode() (WeekToggle, NoteToggle, EntriesToggle)
│ + window._updateWeekToggle(kw) für KW-Sync bei Navigation
├── duration.js ← parseDuration(), roundToQuarter(), formatMinutes(),
│ validateDuration(), initDurationBlurHandler()
├── nav.js ← Hamburger-Navigation (Minimal-Theme)
├── crud.js ← Entry: generisches CRUD (Kunden/Projekte/Leistungen)
├── registration.js ← Entry: Registrierungs-Flow, Live-Slug-Vorschau
├── team.js ← Entry: Team-Verwaltung
├── account.js ← Entry: Account- + Profil-Einstellungen
│ Farbfeld: Picker ↔ Hex-Input synchron,
│ Theme-Picker, Passwort-Toggle
└── report.js ← Entry: Report-Seite, Edit + Invoiced-Toggle
| Entry | Datei | Genutzt auf |
|---|---|---|
app |
assets/app.js |
Timetracking-Wochenansicht |
crud |
assets/scripts/crud.js |
Client/Project/Service-Seiten |
registration |
assets/scripts/registration.js |
Registrierungsseite |
team |
assets/scripts/team.js |
Team-Seite |
account |
assets/scripts/account.js |
Account-Einstellungen |
report |
assets/scripts/report.js |
Report-Seite |
1:30, 8 12 (von-bis), 1,75 (Dezimal), 0:00 (Reset)Account.trackingInterval (1, 15, 30, 60 Minuten)formatMinutes() → "1:30"translations/messages.de.yamlAppExtension.php als Twig-Funktionen: deMonths(), deMonthsShort(), deWeekdays(), deWeekdaysShort()window.TT.i18n (Timetracking) bzw. window.Report.i18n (Report) etc.function t(key) { return window.X?.i18n?.[key] ?? key; }/api/...form_login (Symfony Security), sonst kein CSRF{ error: "..." } mit passendem HTTP-Status{ errors: ["..."] } mit 422| Rolle | Kann |
|---|---|
admin |
Alles: Team verwalten, alle Einträge sehen/bearbeiten, Account-Einstellungen |
member |
Eigene Einträge + alle fremden Einträge sehen (kein Team-Zugriff) |
tracker |
Nur eigene Einträge sehen und bearbeiten |
ArchivedUserChecker)superAdminUser) kann nicht archiviert/gelöscht werden| Version | Inhalt |
|---|---|
| Version20260523* | Initiales Schema (User, Account, AccountUser, Token) |
| Version20260524* | Passwort-Reset, Invite-Flow |
| Version20260526120000 | user.theme VARCHAR(20) DEFAULT ‘standard’ |
| Version20260526150000 | account.primary_color VARCHAR(7) NULL |
Migration ausführen: ddev exec php bin/console doctrine:migrations:migrate --em=central --no-interaction
bash 1-reset-and-seed.sh
# → DB droppen, Migrations ausführen, app:seed aufrufen
bash 2-update-tenant-db.sh
# → Tenant-DB-Schema aktualisieren
app:seed legt an: 1 Account (spawntree), 1 Admin-User, 5 Leistungen (4 verrechenbar, 1 intern), 10 Kunden mit je 1–3 Projekten.
testtimetrackinghttps://testtimetracking.ddev.site:8456https://spawntree.testtimetracking.ddev.site:8456https://testtimetracking.ddev.site:8037db, Passwort db, Central-DB db.env: DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"APP_DOMAIN=testtimetracking.ddev.site:8456 (für Subdomain-Erkennung und E-Mail-Links)