Zeiterfassung + Visual-Polish (Keycap-Buttons, Space Grotesk)
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Zeiterfassung
- Neue Tabelle time_entry mit started_at/ended_at/duration_seconds,
  optional todo_id/bezug, category-Enum (focus/meeting/break/admin/learning/other),
  description, day_entry_id-Verknüpfung. Partial-Unique-Index erzwingt
  max. einen aktiven Timer pro User.
- tRPC time-Router: now, start (stoppt aktiven automatisch), stop,
  listToday (Liste + Tagessumme), update, upsertManual (retroaktiv),
  delete, teamToday (personenübergreifend mit byCategory + aktivem
  Timer pro User).
- TopNav: persistente TimerPill links neben Streak. Quick-Picker mit
  heutigen ToDos, Kategorie-Auswahl und Freitext-Start. Live-Sekundenzähler.
- /heute: TimelineSection mit Edit/Delete pro Eintrag, ToDo-Bindung
  nachträglich änderbar, Block-nachtragen-Dialog. Pro ToDo-Zeile
  ▶-Start- und 🕐-Nachtrag-Button.
- /puls: TeamTimeSection mit aktiver Timer-Anzeige + Tagessumme +
  Kategorie-Breakdown pro User (refresh alle 30s, Live-Tick jede Sekunde).

Visual
- Space Grotesk als Hausschrift (selbst-gehostet, latin + latin-ext)
- Keycap-Button-Stil (Gradient + Druckeffekt) für primary/secondary/destructive
- Neue Helper-Module: format-duration, time-categories

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-06-02 17:27:40 +02:00
1 parent 4f33509a0f
commit 95beb99cd4
23 files changed
+3442 -49

No files matched your search

+37
View File
@@ -364,6 +364,40 @@ export const leadHandover = pgTable('lead_handover', {
uebergebenVon: uuid('uebergeben_von').references(() => user.id, { onDelete: 'set null' }),
});
// ----------------------------------------------------------------------------
// time_entry — Zeiterfassung. Pro User darf nur ein Eintrag mit ended_at IS NULL
// gleichzeitig existieren (= aktiver Timer). Optional an ToDo oder Bezug gebunden.
// ----------------------------------------------------------------------------
export const timeEntry = pgTable(
'time_entry',
{
id: uuid('id').default(sql`gen_random_uuid()`).primaryKey(),
userId: uuid('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
dayEntryId: uuid('day_entry_id').references(() => dayEntry.id, { onDelete: 'set null' }),
todoId: uuid('todo_id').references(() => todo.id, { onDelete: 'set null' }),
bezugKind: text('bezug_kind', { enum: ['project', 'deal', 'campaign', 'allgemein'] }),
bezugId: uuid('bezug_id'),
category: text('category', {
enum: ['focus', 'meeting', 'break', 'admin', 'learning', 'other'],
})
.notNull()
.default('focus'),
description: text('description'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
endedAt: timestamp('ended_at', { withTimezone: true }),
durationSeconds: integer('duration_seconds'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxUserStart: index('idx_time_entry_user_start').on(t.userId, t.startedAt),
idxUserActive: uniqueIndex('uniq_time_entry_user_active')
.on(t.userId)
.where(sql`${t.endedAt} IS NULL`),
idxTodo: index('idx_time_entry_todo').on(t.todoId),
}),
);
// ----------------------------------------------------------------------------
// blocker — bereichsübergreifendes Hindernis. Wird inline in der Reflexion
// oder über „Neuer Blocker"-Button (Story 5.3) erfasst.
@@ -415,6 +449,9 @@ export type NewTodo = typeof todo.$inferInsert;
export type AbsenceDay = typeof absenceDay.$inferSelect;
export type NewAbsenceDay = typeof absenceDay.$inferInsert;
export type TimeEntry = typeof timeEntry.$inferSelect;
export type NewTimeEntry = typeof timeEntry.$inferInsert;
export type Blocker = typeof blocker.$inferSelect;
export type NewBlocker = typeof blocker.$inferInsert;