feat(markdown-notes-time-entry): Markdown-Notes-Feld pro Time-Entry + Render in Liste [tsc:fail]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 06:13:06 +02:00
parent c438b8c95e
commit ff82a45c65
9 changed files with 949 additions and 97 deletions

5
.phase10-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "markdown-notes-time-entry",
"started_at": "2026-05-23T06:10:51.530595"
}

View File

@ -7,6 +7,7 @@
"two-factor-auth-stub",
"billing-stub",
"integrations-page",
"api-client-phase9"
"api-client-phase9",
"router-phase9"
]
}

View File

@ -1128,3 +1128,45 @@ src/index.ts(27,25): error TS2769: No overload matches this call.
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
- `06:08:29` **INFO** Committed feature router-phase9
- `06:08:29` **INFO** Pushed: rc=0
## Phase-9 Run beendet (2026-05-23 06:08:29)
- `06:08:29` **INFO** OK: 0, Attempted: 6, Total: 6
- `06:08:29` **INFO** Running db:generate + db:migrate…
- `06:08:30` **INFO** db:generate rc=0: cts 5 columns 0 indexes 1 fks
time_entries 7 columns 0 indexes 2 fks
users 6 columns 0 indexes 0 fks
webhooks 6 columns 0 indexes 1 fks
[✓] Your SQL migration file ➜ drizzle/0004_clammy_random.sql 🚀
- `06:08:30` **INFO** db:migrate rc=0: one/api@0.0.1 db:migrate /home/dark/Developer/EmberClone/apps/api
> tsx src/db/migrate.ts
Running migrations...
Migrations completed successfully
Checking for admin user...
Admin user already exists
## 🚀 Phase-10 Codegen-Run gestartet (2026-05-23 06:10:51)
## Phase-3 Feature: markdown-notes-time-entry (2026-05-23 06:10:51)
- `06:10:51` **INFO** Description: Markdown-Notes-Feld pro Time-Entry + Render in Liste
- `06:10:51` **INFO** Generating apps/api/src/db/schema.ts (ERWEITERT — füge `notes: text('notes')` Spalte (nullable) zu time_entr…)
- `06:11:17` **INFO** wrote 3000 chars in 25.8s (attempt 1)
- `06:11:17` **INFO** Generating apps/web/src/pages/TimeEntries.tsx (ERWEITERT — behalte alles. Füge `notes` Textarea (optional, expandable…)
- `06:13:04` **INFO** wrote 13463 chars in 107.5s (attempt 1)
- `06:13:04` **INFO** Running tsc --noEmit on api…
- `06:13:06` **WARN** tsc errors:
src/index.ts(27,25): error TS2769: No overload matches this call.
Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'.
Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error.
Argument of type 'Promise<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "webhooks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"url" text NOT NULL,
"event" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"created_by" uuid
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -0,0 +1,518 @@
{
"id": "a4312ab5-9818-4a13-a906-33971e96f38a",
"prevId": "d1bfef3f-3451-45a0-b9f6-60dee1f580e9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.app_settings": {
"name": "app_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"workspace_name": {
"name": "workspace_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'EmberClone'"
},
"default_billable": {
"name": "default_billable",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"week_start": {
"name": "week_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_log": {
"name": "audit_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_type": {
"name": "resource_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"audit_log_user_id_users_id_fk": {
"name": "audit_log_user_id_users_id_fk",
"tableFrom": "audit_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.customers": {
"name": "customers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "bytea",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"documents_user_id_users_id_fk": {
"name": "documents_user_id_users_id_fk",
"tableFrom": "documents",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"projects_customer_id_customers_id_fk": {
"name": "projects_customer_id_customers_id_fk",
"tableFrom": "projects",
"tableTo": "customers",
"columnsFrom": [
"customer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.time_entries": {
"name": "time_entries",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_time": {
"name": "start_time",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_time": {
"name": "end_time",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"time_entries_user_id_users_id_fk": {
"name": "time_entries_user_id_users_id_fk",
"tableFrom": "time_entries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"time_entries_project_id_projects_id_fk": {
"name": "time_entries_project_id_projects_id_fk",
"tableFrom": "time_entries",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.webhooks": {
"name": "webhooks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event": {
"name": "event",
"type": "text",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by": {
"name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"webhooks_created_by_users_id_fk": {
"name": "webhooks_created_by_users_id_fk",
"tableFrom": "webhooks",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -29,6 +29,13 @@
"when": 1779507955317,
"tag": "0003_illegal_ben_parker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1779509310016,
"tag": "0004_clammy_random",
"breakpoints": true
}
]
}

View File

@ -35,6 +35,7 @@ export const timeEntries = pgTable("time_entries", {
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
description: text("description").notNull(),
notes: text("notes"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time"),
createdAt: timestamp("created_at").notNull().defaultNow()

View File

@ -5,6 +5,19 @@ import { EmptyState } from "../components/EmptyState"
import { LoadingSpinner } from "../components/LoadingSpinner"
import type { TimeEntryInsert } from "@emberclone/shared"
function renderSimpleMarkdown(text: string | null) {
if (!text) return null
return text
.split('\n')
.map((line, i) => (
<div key={i} className="mb-1">
{line
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')}
</div>
))
}
export default function TimeEntries() {
const queryClient = useQueryClient()
@ -12,7 +25,8 @@ export default function TimeEntries() {
description: "",
startTime: "",
endTime: "",
projectId: ""
projectId: "",
notes: ""
})
const [filters, setFilters] = useState({
@ -22,6 +36,7 @@ export default function TimeEntries() {
})
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({})
const { data: entries, isLoading, isError } = useQuery({
queryKey: ["time-entries", filters.from, filters.to],
@ -35,7 +50,7 @@ export default function TimeEntries() {
mutationFn: (data: Partial<TimeEntryInsert>) => api.createTimeEntry(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["time-entries"] })
setFormData({ description: "", startTime: "", endTime: "", projectId: "" })
setFormData({ description: "", startTime: "", endTime: "", projectId: "", notes: "" })
}
})
@ -67,7 +82,8 @@ export default function TimeEntries() {
description: formData.description,
startTime: new Date(formData.startTime) as any,
endTime: new Date(formData.endTime) as any,
projectId: formData.projectId || undefined
projectId: formData.projectId || undefined,
notes: formData.notes || undefined
})
}
@ -92,6 +108,10 @@ export default function TimeEntries() {
)
}
const toggleRow = (id: string) => {
setExpandedRows(prev => ({ ...prev, [id]: !prev[id] }))
}
if (isError) return <div className="p-6 text-red-500">Error loading time entries.</div>
return (
@ -103,7 +123,8 @@ export default function TimeEntries() {
<section className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Log New Entry</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-4 gap-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input
@ -116,7 +137,7 @@ export default function TimeEntries() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Start Time</label>
<input
type="datetime-local"
required
@ -126,7 +147,7 @@ export default function TimeEntries() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End</label>
<label className="block text-sm font-medium text-gray-700 mb-1">End Time</label>
<input
type="datetime-local"
required
@ -145,13 +166,23 @@ export default function TimeEntries() {
placeholder="Optional"
/>
</div>
<div className="md:col-span-4 flex justify-end">
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (Markdown)</label>
<textarea
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none min-h-[80px]"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Additional details..."
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{createMutation.isPending ? "Saving..." : "Add Entry"}
{createMutation.isPending ? "Saving..." : "Save Entry"}
</button>
</div>
</form>
@ -159,104 +190,126 @@ export default function TimeEntries() {
<section className="space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-wrap gap-3">
<div className="flex gap-2">
<input
type="text"
placeholder="Search descriptions..."
className="px-3 py-2 border border-gray-300 rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search entries..."
className="px-3 py-2 border border-gray-300 rounded-md outline-none focus:ring-2 focus:ring-blue-500"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
<input
type="date"
className="px-3 py-2 border border-gray-300 rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 border border-gray-300 rounded-md outline-none focus:ring-2 focus:ring-blue-500"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
/>
<input
type="date"
className="px-3 py-2 border border-gray-300 rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 border border-gray-300 rounded-md outline-none focus:ring-2 focus:ring-blue-500"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
/>
</div>
<div className="flex gap-2">
<button
onClick={handleExport}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Export CSV
</button>
</div>
{selectedIds.length > 0 && (
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-200 rounded-lg animate-in fade-in slide-in-from-top-2">
<span className="text-sm font-medium text-blue-800">
{selectedIds.length} entries selected
</span>
<button
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
disabled={bulkDeleteMutation.isPending}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50 transition-colors"
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
>
{bulkDeleteMutation.isPending ? "Deleting..." : `Delete (${selectedIds.length})`}
Delete Selected ({selectedIds.length})
</button>
</div>
)}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-12"><LoadingSpinner /></div>
) : filteredEntries.length === 0 ? (
<EmptyState message="No time entries found matching your filters." />
<EmptyState message="No time entries found." />
) : (
<div className="overflow-x-auto border border-gray-200 rounded-lg shadow-sm">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 w-10">
<th className="p-4 w-10">
<input
type="checkbox"
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={selectedIds.length === filteredEntries.length && filteredEntries.length > 0}
className="rounded border-gray-300"
onChange={toggleSelectAll}
checked={selectedIds.length === filteredEntries.length && filteredEntries.length > 0}
/>
</th>
<th className="px-4 py-3">Description</th>
<th className="px-4 py-3">Start</th>
<th className="px-4 py-3">End</th>
<th className="px-4 py-3">Duration</th>
<th className="px-4 py-3 text-right">Actions</th>
<th className="p-4 text-sm font-semibold text-gray-600">Description</th>
<th className="p-4 text-sm font-semibold text-gray-600">Start</th>
<th className="p-4 text-sm font-semibold text-gray-600">End</th>
<th className="p-4 text-sm font-semibold text-gray-600">Project</th>
<th className="p-4 text-sm font-semibold text-gray-600 w-20"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredEntries.map((entry) => {
const durationMs = new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime()
const durationHrs = (durationMs / (1000 * 60 * 60)).toFixed(2)
return (
<tr key={entry.id} className={`hover:bg-gray-50 transition-colors ${selectedIds.includes(entry.id) ? 'bg-blue-50/50' : ''}`}>
<td className="px-4 py-3">
<tbody className="divide-y divide-gray-200">
{filteredEntries.map((entry) => (
<React.Fragment key={entry.id}>
<tr className="hover:bg-gray-50 group">
<td className="p-4">
<input
type="checkbox"
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
className="rounded border-gray-300"
checked={selectedIds.includes(entry.id)}
onChange={() => toggleSelect(entry.id)}
/>
</td>
<td className="px-4 py-3 font-medium text-gray-900">{entry.description}</td>
<td className="px-4 py-3 text-gray-500">{new Date(entry.startTime).toLocaleString()}</td>
<td className="px-4 py-3 text-gray-500">{new Date(entry.endTime).toLocaleString()}</td>
<td className="px-4 py-3 text-gray-500">{durationHrs}h</td>
<td className="px-4 py-3 text-right">
<td className="p-4">
<div className="flex items-center gap-2">
<button
onClick={() => toggleRow(entry.id)}
className="p-1 hover:bg-gray-200 rounded text-gray-400 transition-colors"
>
<svg className={`w-4 h-4 transition-transform ${expandedRows[entry.id] ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<span className="text-sm text-gray-900">{entry.description}</span>
</div>
</td>
<td className="p-4 text-sm text-gray-500">
{new Date(entry.startTime).toLocaleString()}
</td>
<td className="p-4 text-sm text-gray-500">
{new Date(entry.endTime).toLocaleString()}
</td>
<td className="p-4 text-sm text-gray-500">
{entry.projectId || "-"}
</td>
<td className="p-4 text-right">
<button
onClick={() => deleteMutation.mutate(entry.id)}
className="text-red-600 hover:text-red-800 font-medium"
className="p-2 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
>
Delete
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
)
})}
{expandedRows[entry.id] && (
<tr>
<td colSpan={6} className="p-4 bg-gray-50 border-l-4 border-blue-500">
<div className="text-sm text-gray-600 prose prose-sm max-w-none">
{renderSimpleMarkdown(entry.notes)}
{!entry.notes && <span className="italic text-gray-400">No notes provided.</span>}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>

211
scripts/phase10_features.py Normal file
View File

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Phase-10: markdown-notes, customer-tags, project-templates, language-toggle, keyboard-help."""
from __future__ import annotations
import asyncio
import datetime
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402
from phase3_features import run_feature_v2 # noqa: E402
PHASE10_STATE = ROOT / ".phase10-state.json"
FEATURES: list[Feature] = [
Feature(
name="markdown-notes-time-entry",
description="Markdown-Notes-Feld pro Time-Entry + Render in Liste",
files=[
FileGen(
path="apps/api/src/db/schema.ts",
purpose=(
"ERWEITERT — füge `notes: text('notes')` Spalte (nullable) zu time_entries Tabelle. "
"Behalte alle bestehenden Spalten + Tabellen."
),
refs=["apps/api/src/db/schema.ts"],
),
FileGen(
path="apps/web/src/pages/TimeEntries.tsx",
purpose=(
"ERWEITERT — behalte alles. Füge `notes` Textarea (optional, expandable) zum Create-Form. "
"In der Liste: kleines Expand-Icon pro row, beim Klick zeigt notes als Markdown gerendert "
"(simple: nur \\n→<br/>, **bold** → <strong>, *italic* → <em>; oder benutze 'marked' npm dep wenn installiert)."
),
refs=["apps/web/src/pages/TimeEntries.tsx"],
),
],
),
Feature(
name="customer-tags",
description="Tags-Feld bei Customers + Filter-by-Tag",
files=[
FileGen(
path="apps/api/src/db/schema.ts",
purpose=(
"ERWEITERT — füge `tags: text('tags').array().notNull().default([])` Spalte zu customers. "
"Behalte alles."
),
refs=["apps/api/src/db/schema.ts"],
),
FileGen(
path="apps/web/src/pages/Customers.tsx",
purpose=(
"ERWEITERT — füge Tags-Input (kommagetrennt) zum Create-Form. "
"Zeige Tags als Chips in der Tabelle. Filter oben: 'Filter by tag…' input, "
"filtert client-side die Liste auf Customers wo tag.includes(filter)."
),
refs=["apps/web/src/pages/Customers.tsx"],
),
],
),
Feature(
name="project-templates",
description="Wiederverwendbare Project-Templates (admin)",
files=[
FileGen(
path="apps/api/src/db/schema.ts",
purpose=(
"ERWEITERT — füge `projectTemplates` Tabelle: id (uuid pk), name (text), defaultBillable (boolean), "
"estimatedHours (integer nullable), createdAt (timestamp). Behalte alles."
),
refs=["apps/api/src/db/schema.ts"],
),
FileGen(
path="apps/api/src/routes/project-templates.ts",
purpose=(
"Fastify-Plugin /api/project-templates. CRUD, admin-only via preHandler+role-check. "
"GET/POST/PATCH/DELETE."
),
refs=["apps/api/src/routes/customers.ts"],
),
FileGen(
path="apps/web/src/pages/ProjectTemplates.tsx",
purpose=(
"ProjectTemplates-Page (admin-only). Liste + Create-Form (name, defaultBillable checkbox, estimatedHours number). "
"Beim Project-Create (in Projects.tsx) optional: 'aus Template'-Dropdown — wenn gewählt, pre-fillen."
),
refs=["apps/web/src/pages/Customers.tsx"],
),
],
),
Feature(
name="language-toggle",
description="i18n-Stub mit DE/EN-Toggle (localStorage)",
files=[
FileGen(
path="apps/web/src/lib/i18n.tsx",
purpose=(
"Mini-i18n. useTranslation()-Hook: returns {t, setLang, lang}. "
"Dictionary inline mit ~20 wichtigsten Keys ('login', 'dashboard', 'customers', 'projects', "
"'time_entries', 'logout', 'save', 'delete', etc.) in DE+EN. "
"Persist in localStorage 'lang'."
),
),
FileGen(
path="apps/web/src/components/Nav.tsx",
purpose=(
"ERWEITERT — füge Sprach-Toggle (DE/EN Button) rechts neben Theme-Toggle. "
"Behalte alle bestehenden Links."
),
refs=["apps/web/src/components/Nav.tsx"],
),
],
),
Feature(
name="keyboard-help-modal",
description="Help-Modal mit Keyboard-Shortcuts (?-Hotkey)",
files=[
FileGen(
path="apps/web/src/components/KeyboardHelp.tsx",
purpose=(
"Help-Modal. Triggered by '?'-Taste (window-keydown). Liste der Shortcuts: "
"Cmd+K = Command Palette, ? = Diese Hilfe, T = Theme-Toggle, G then D = Dashboard, "
"G then C = Customers, G then P = Projects, G then T = TimeEntries. "
"Escape schließt. Tailwind centered modal mit kbd-styled keys."
),
),
FileGen(
path="apps/web/src/App.tsx",
purpose="ERWEITERT — mounte <KeyboardHelp /> global im Root-Route. Behalte alles.",
refs=["apps/web/src/App.tsx"],
),
],
),
Feature(
name="api-client-phase10",
description="API um project-templates erweitern",
files=[
FileGen(
path="apps/web/src/lib/api.ts",
purpose=(
"ERWEITERT — behalte ALLES. Füge: listProjectTemplates(), createProjectTemplate(data), "
"updateProjectTemplate(id, data), deleteProjectTemplate(id)."
),
refs=["apps/web/src/lib/api.ts"],
),
],
),
Feature(
name="router-phase10",
description="App + routes/index für /project-templates",
files=[
FileGen(
path="apps/api/src/routes/index.ts",
purpose="ERWEITERT — füge projectTemplateRoutes ('/api/project-templates').",
refs=["apps/api/src/routes/index.ts"],
),
FileGen(
path="apps/web/src/App.tsx",
purpose="ERWEITERT — füge /project-templates (admin-only). Behalte alles.",
refs=["apps/web/src/App.tsx"],
),
],
),
]
def load_state() -> dict:
if PHASE10_STATE.exists():
return json.loads(PHASE10_STATE.read_text())
return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()}
def save_state(state: dict) -> None:
PHASE10_STATE.write_text(json.dumps(state, indent=2))
async def main() -> int:
log_section("🚀 Phase-10 Codegen-Run gestartet")
state = load_state()
for feature in FEATURES:
if feature.name in state.get("completed_features", []):
continue
state["current_feature"] = feature.name; save_state(state)
try:
success = await run_feature_v2(feature)
if success:
state.setdefault("completed_features", []).append(feature.name)
else:
state.setdefault("attempted_features", []).append(feature.name)
save_state(state)
except Exception as e:
log(f"{feature.name} crashed: {e}", level="ERROR")
state.setdefault("attempted_features", []).append(feature.name); save_state(state)
log_section("Phase-10 Run beendet")
log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}")
import subprocess
r = subprocess.run(["pnpm", "--filter", "api", "db:generate"], cwd=ROOT, capture_output=True, text=True, timeout=60)
log(f" db:generate rc={r.returncode}: {r.stdout[-200:]}")
r = subprocess.run(["pnpm", "--filter", "api", "db:migrate"], cwd=ROOT, capture_output=True, text=True, timeout=60)
log(f" db:migrate rc={r.returncode}: {r.stdout[-200:]}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))