Zeiterfassung + Visual-Polish (Keycap-Buttons, Space Grotesk)
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:
1 parent
4f33509a0f
commit
95beb99cd4
23 files changed
+3442
-49
No files matched your search
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "time_entry" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"day_entry_id" uuid,
|
||||
"todo_id" uuid,
|
||||
"bezug_kind" text,
|
||||
"bezug_id" uuid,
|
||||
"category" text DEFAULT 'focus' NOT NULL,
|
||||
"description" text,
|
||||
"started_at" timestamp with time zone NOT NULL,
|
||||
"ended_at" timestamp with time zone,
|
||||
"duration_seconds" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_day_entry_id_day_entry_id_fk" FOREIGN KEY ("day_entry_id") REFERENCES "public"."day_entry"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_todo_id_todo_id_fk" FOREIGN KEY ("todo_id") REFERENCES "public"."todo"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_time_entry_user_start" ON "time_entry" USING btree ("user_id","started_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "uniq_time_entry_user_active" ON "time_entry" USING btree ("user_id") WHERE "time_entry"."ended_at" IS NULL;--> statement-breakpoint
|
||||
CREATE INDEX "idx_time_entry_todo" ON "time_entry" USING btree ("todo_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,13 @@
|
||||
"when": 1779740456944,
|
||||
"tag": "0005_todo_carry_over",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1780410287425,
|
||||
"tag": "0006_time_entry",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ export * as DayEntrySchemas from './schemas/day-entry.js';
|
||||
export * as ProjectSchemas from './schemas/project.js';
|
||||
export * as DealSchemas from './schemas/deal.js';
|
||||
export * as CampaignSchemas from './schemas/campaign.js';
|
||||
export * as TimeEntrySchemas from './schemas/time-entry.js';
|
||||
|
||||
export const SHARED_VERSION = '0.0.2';
|
||||
export const SHARED_VERSION = '0.0.3';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const timeCategorySchema = z.enum([
|
||||
'focus',
|
||||
'meeting',
|
||||
'break',
|
||||
'admin',
|
||||
'learning',
|
||||
'other',
|
||||
]);
|
||||
export type TimeCategory = z.infer<typeof timeCategorySchema>;
|
||||
|
||||
export const TIME_CATEGORIES: { value: TimeCategory; label: string }[] = [
|
||||
{ value: 'focus', label: 'Fokus' },
|
||||
{ value: 'meeting', label: 'Meeting' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'learning', label: 'Lernen' },
|
||||
{ value: 'break', label: 'Pause' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
export const startTimeEntryInput = z.object({
|
||||
todoId: z.string().uuid().optional(),
|
||||
category: timeCategorySchema.default('focus'),
|
||||
description: z.string().max(200).optional(),
|
||||
});
|
||||
export type StartTimeEntryInput = z.infer<typeof startTimeEntryInput>;
|
||||
|
||||
export const updateTimeEntryInput = z.object({
|
||||
id: z.string().uuid(),
|
||||
startedAt: z.string().datetime().optional(),
|
||||
endedAt: z.string().datetime().nullable().optional(),
|
||||
todoId: z.string().uuid().nullable().optional(),
|
||||
category: timeCategorySchema.optional(),
|
||||
description: z.string().max(200).nullable().optional(),
|
||||
});
|
||||
export type UpdateTimeEntryInput = z.infer<typeof updateTimeEntryInput>;
|
||||
|
||||
export const manualTimeEntryInput = z.object({
|
||||
startedAt: z.string().datetime(),
|
||||
endedAt: z.string().datetime(),
|
||||
todoId: z.string().uuid().optional(),
|
||||
category: timeCategorySchema.default('focus'),
|
||||
description: z.string().max(200).optional(),
|
||||
});
|
||||
export type ManualTimeEntryInput = z.infer<typeof manualTimeEntryInput>;
|
||||
Reference in new issue
Block a user