This commit is contained in:
Gal Podlipnik 2025-07-14 19:40:13 +02:00
parent 6166350e56
commit 986d34ab12
12 changed files with 950 additions and 290 deletions

View File

@ -0,0 +1,69 @@
---
applyTo: "**"
---
You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices. You use the features and best practices according to the latest Angular version. You write code that is easy to read, understand and maintain. You avoid unnecessary complexity and strive for simplicity in your code. You use the latest features of TypeScript and Angular to write clean, efficient and scalable code.
## TypeScript Best Practices
- Use strict type checking
- Prefer type interfaces when the type is obvious
- Avoid the `any` type; use `unknown` when type is uncertain
- Use `readonly` #<fieldName> for private class fields
- Use `const` for constants and `let` for variables that may change
- Use the following syntax for `enums`:
```typescript
export enum WeekDayEnum = <const>{
MONDAY: 'Monday',
TUESDAY: 'Tuesday',
WEDNESDAY: 'Wednesday',
THURSDAY: 'Thursday',
FRIDAY: 'Friday',
SATURDAY: 'Saturday',
SUNDAY: 'Sunday'
};
export type WeekDay = (typeof WeekDayEnum)[keyof typeof WeekDayEnum];
```
- Add all types and interfaces to a `<entity>.types.ts` file
## Angular Best Practices
- Always use standalone components over NgModules
- Don't use explicit `standalone: true` (it is implied by default)
- Use signals for state management
- Implement lazy loading for feature routes
- Use `NgOptimizedImage` for all static images
## Components
- Keep components small and focused on a single responsibility
- Use `input()` and `output()` functions instead of decorators
- Use `computed()` for derived state
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
- Prefer inline templates for small components
- Prefer Reactive forms instead of Template-driven ones
- Do NOT use `ngClass`, use `class` bindings instead
- Do NOT use `ngStyle`, use `style` bindings instead
## State Management
- Use signals for local component state
- Use `computed()` for derived state
- Keep state transformations pure and predictable
## Templates
- Keep templates simple and avoid complex logic
- Use Signals for your template bindings whenever possible
- Use untagged template literals in the template instead of concatenation
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, and `*ngSwitch`
- Use the async pipe to handle Observables
## Services
- Design services around a single responsibility
- Use the `providedIn: 'root'` option for singleton services
- Use the `inject()` function instead of constructor injection

View File

@ -9,7 +9,7 @@ export type Task = {
id: number; id: number;
title: string; title: string;
description?: string; description?: string;
user_id: number; userId: number;
timestamp?: string; timestamp?: string;
}; };
@ -21,8 +21,8 @@ export type TaskResponse = {
export type Comment = { export type Comment = {
id: number; id: number;
content: string; content: string;
task_id: number; taskId: number;
user_id: number; userId: number;
timestamp?: string; timestamp?: string;
}; };

View File

@ -1,10 +1,13 @@
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
@Component({ @Component({
selector: 'lib-navbar', selector: 'lib-navbar',
imports: [MatToolbarModule, MatIcon], imports: [MatToolbarModule, MatIcon, MatButtonModule, RouterModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<mat-toolbar color="primary" class="navbar"> <mat-toolbar color="primary" class="navbar">
<span class="logo"> <span class="logo">
@ -36,17 +39,6 @@ import { MatToolbarModule } from '@angular/material/toolbar';
.spacer { .spacer {
flex: 1 1 auto; flex: 1 1 auto;
} }
a[mat-button] {
font-weight: 500;
font-size: 1rem;
margin-left: 1rem;
transition: background 0.2s, color 0.2s;
border-radius: 4px;
}
a[mat-button]:hover, a[mat-button].active-link {
background: rgba(255,255,255,0.15);
color: #fff;
}
`, `,
], ],
}) })

View File

@ -1,2 +1,3 @@
export * from './lib/task-list.component'; export * from './lib/task-list.component';
export * from './lib/task-form.component'; export * from './lib/task-form.component';
export * from './lib/add-task-dialog.component';

View File

@ -1,39 +1,195 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog'; import {
import { TaskFormComponent } from './task-form.component'; MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { TasksService, User, UsersService } from '@org/shared/api';
import { ToastService } from '@org/shared/toast';
@Component({ @Component({
selector: 'lib-add-task-dialog', selector: 'lib-add-task-dialog',
standalone: true,
template: ` template: `
<h2 mat-dialog-title>Add New Task</h2> <h2 mat-dialog-title class="dialog-title">Add New Task</h2>
<mat-dialog-content> <mat-dialog-content class="dialog-content">
<lib-task-form (taskAdded)="onTaskAdded()"></lib-task-form> <form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
</mat-dialog-content> <mat-form-field appearance="outline" class="full-width">
<mat-dialog-actions align="end"> <mat-label>Assigned to</mat-label>
<button mat-button mat-dialog-close type="button">Cancel</button> <mat-select formControlName="userId" required>
</mat-dialog-actions> @for (user of users(); track user.id) {
`, <mat-option [value]="user.id">{{ user.name }}</mat-option>
styles: [`
mat-dialog-content {
min-width: 320px;
} }
`], </mat-select>
@if (taskForm.get('userId')?.hasError('required') &&
taskForm.get('userId')?.touched) {
<mat-error>Please select a user</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Title</mat-label>
<input matInput formControlName="title" placeholder="Task title" />
@if (taskForm.get('title')?.hasError('required') &&
taskForm.get('title')?.touched) {
<mat-error>Title is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea
matInput
formControlName="description"
placeholder="Task description"
rows="3"
></textarea>
</mat-form-field>
<mat-dialog-actions align="end" class="dialog-actions">
<button mat-stroked-button mat-dialog-close type="button">
Cancel
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="taskForm.invalid"
>
<mat-icon>add_task</mat-icon>
<span class="button-text">Add Task</span>
</button>
</mat-dialog-actions>
</form>
</mat-dialog-content>
`,
styles: [
`
.dialog-title {
font-weight: 700;
font-size: 1.3rem;
color: #1976d2;
margin-bottom: 0.5rem;
}
.dialog-content {
min-width: 400px;
padding-top: 0.5rem;
}
.full-width {
width: 100%;
margin: 1.2rem 0;
}
.dialog-actions {
margin-top: 1.2rem;
}
button[mat-raised-button] {
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-height: 44px;
padding: 0.75rem 1rem;
white-space: normal;
line-height: 1.3;
}
button[mat-raised-button] mat-icon {
margin: 0;
flex-shrink: 0;
margin-top: 0.1rem;
}
button[mat-raised-button] .button-text {
flex: 1;
text-align: left;
}
@media (max-width: 600px) {
.dialog-content {
min-width: 90vw;
}
}
`,
],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatDialogActions, MatDialogActions,
MatDialogClose, MatDialogClose,
MatDialogTitle, MatDialogTitle,
MatDialogContent, MatDialogContent,
TaskFormComponent, MatFormFieldModule,
MatInputModule,
MatIconModule,
MatSelectModule,
ReactiveFormsModule,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AddTaskDialogComponent { export class AddTaskDialogComponent {
readonly dialogRef = inject(MatDialogRef<AddTaskDialogComponent>); readonly dialogRef = inject(MatDialogRef<AddTaskDialogComponent>);
private readonly fb = inject(FormBuilder);
private readonly tasksService = inject(TasksService);
private readonly usersService = inject(UsersService);
private readonly toastService = inject(ToastService);
onTaskAdded() { readonly users = signal<User[]>([]);
taskForm: FormGroup = this.fb.group({
userId: ['', Validators.required],
title: ['', Validators.required],
description: [''],
});
constructor() {
this.loadUsers();
}
private loadUsers() {
this.usersService.getUsers().subscribe({
next: (users) => {
this.users.set(users);
},
error: (error) => {
console.error('Error loading users:', error);
this.toastService.show('Failed to load users!', 'error');
},
});
}
onSubmit() {
if (this.taskForm.invalid) return;
const task = {
title: this.taskForm.value.title,
description: this.taskForm.value.description,
userId: parseInt(this.taskForm.value.userId),
};
this.tasksService.createTask(task).subscribe({
next: () => {
this.taskForm.reset();
this.toastService.show('Task created successfully!');
this.dialogRef.close(true); this.dialogRef.close(true);
},
error: (error) => {
console.error('Error creating task:', error);
this.toastService.show('Failed to create task!', 'error');
},
});
} }
} }

View File

@ -1,5 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Output, inject, input } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -9,25 +15,27 @@ import {
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TasksService } from '@org/shared/api'; import { TasksService } from '@org/shared/api';
import { ToastService } from '@org/shared/toast'; import { ToastService } from '@org/shared/toast';
@Component({ @Component({
selector: 'lib-task-form', selector: 'lib-task-form',
standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
ReactiveFormsModule, ReactiveFormsModule,
MatFormFieldModule, MatFormFieldModule,
MatIconModule,
MatInputModule, MatInputModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
], ],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<mat-card> <mat-card class="task-form-card">
<mat-card-header> <mat-card-header>
<mat-card-title>Add New Task</mat-card-title> <mat-card-title class="form-title">Add New Task</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()"> <form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
@ -46,41 +54,79 @@ import { ToastService } from '@org/shared/toast';
matInput matInput
formControlName="description" formControlName="description"
placeholder="Task description" placeholder="Task description"
rows="3"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
<div class="form-actions">
<button <button
mat-raised-button mat-raised-button
color="primary" color="primary"
type="submit" type="submit"
[disabled]="taskForm.invalid" [disabled]="taskForm.invalid"
> >
Add Task <mat-icon>add_task</mat-icon>
<span class="button-text">Add Task</span>
</button> </button>
</div>
</form> </form>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
`, `,
styles: [ styles: [
` `
.full-width { .task-form-card {
width: 100%; margin: 2rem 0;
margin-bottom: 1rem; max-width: 600px;
} }
button { .form-title {
margin-top: 1rem; font-weight: 700;
color: #1976d2;
margin-bottom: 0.5rem;
}
.full-width {
width: 100%;
margin: 1.2rem 0;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 1.2rem;
}
button[mat-raised-button] {
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-height: 44px;
padding: 0.75rem 1rem;
white-space: normal;
line-height: 1.3;
}
button[mat-raised-button] mat-icon {
margin: 0;
flex-shrink: 0;
margin-top: 0.1rem;
}
button[mat-raised-button] .button-text {
flex: 1;
text-align: left;
} }
`, `,
], ],
}) })
export class TaskFormComponent { export class TaskFormComponent {
userId = input<number>(0); readonly userId = input<number>(0);
@Output() taskAdded = new EventEmitter<void>(); readonly taskAdded = output<void>();
private fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private tasksService = inject(TasksService); private readonly tasksService = inject(TasksService);
private toastService = inject(ToastService); private readonly toastService = inject(ToastService);
taskForm: FormGroup = this.fb.group({ taskForm: FormGroup = this.fb.group({
title: ['', Validators.required], title: ['', Validators.required],
@ -93,7 +139,7 @@ export class TaskFormComponent {
const task = { const task = {
title: this.taskForm.value.title, title: this.taskForm.value.title,
description: this.taskForm.value.description, description: this.taskForm.value.description,
user_id: this.userId(), userId: this.userId(),
}; };
this.tasksService.createTask(task).subscribe({ this.tasksService.createTask(task).subscribe({

View File

@ -1,7 +1,10 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core'; import {
import { MatDialog } from '@angular/material/dialog'; ChangeDetectionStrategy,
import { AddTaskDialogComponent } from './add-task-dialog.component'; Component,
inject,
signal,
} from '@angular/core';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -11,6 +14,7 @@ import {
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion'; import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -31,10 +35,10 @@ import {
} from '@org/shared/api'; } from '@org/shared/api';
import { ToastService } from '@org/shared/toast'; import { ToastService } from '@org/shared/toast';
import { catchError, finalize, forkJoin, of } from 'rxjs'; import { catchError, finalize, forkJoin, of } from 'rxjs';
import { AddTaskDialogComponent } from './add-task-dialog.component';
@Component({ @Component({
selector: 'lib-task-list', selector: 'lib-task-list',
standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
RouterModule, RouterModule,
@ -52,17 +56,18 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
MatBadgeModule, MatBadgeModule,
MatTooltipModule, MatTooltipModule,
], ],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="page-container"> <div class="page-container">
<mat-toolbar color="primary" class="page-header"> <mat-toolbar color="primary" class="page-header">
<span>Task Management</span> <span>Tasks</span>
<span class="spacer"></span> <span class="spacer"></span>
<button mat-icon-button matTooltip="Refresh data" (click)="loadData()"> <button mat-icon-button matTooltip="Refresh data" (click)="loadData()">
<mat-icon>refresh</mat-icon> <mat-icon>refresh</mat-icon>
</button> </button>
</mat-toolbar> </mat-toolbar>
<button class="fab" mat-fab color="accent" (click)="openAddTaskDialog()"> <button class="fab" matFab color="accent" (click)="openAddTaskDialog()">
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
</button> </button>
@ -86,6 +91,14 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
<div class="task-title-container"> <div class="task-title-container">
<mat-icon>assignment</mat-icon> <mat-icon>assignment</mat-icon>
<span>{{ task.tasks.title }}</span> <span>{{ task.tasks.title }}</span>
</div>
</mat-panel-title>
<mat-panel-description>
<div class="task-info">
<div class="task-info-row">
<span class="task-author"
>By {{ getUserName(task.tasks.userId) }}</span
>
@if (getTaskComments(task.tasks.id).length) { @if (getTaskComments(task.tasks.id).length) {
<mat-icon <mat-icon
class="comment-badge" class="comment-badge"
@ -96,12 +109,6 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
</mat-icon> </mat-icon>
} }
</div> </div>
</mat-panel-title>
<mat-panel-description>
<div class="task-info">
<span class="task-author"
>By {{ getUserName(task.tasks.user_id) }}</span
>
<span class="task-timestamp">{{ <span class="task-timestamp">{{
formatDate(task.tasks.timestamp) formatDate(task.tasks.timestamp)
}}</span> }}</span>
@ -213,29 +220,20 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
bottom: 2rem; bottom: 2rem;
right: 2rem; right: 2rem;
z-index: 1100; z-index: 1100;
}
.dialog-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1200;
}
.dialog-card {
min-width: 320px;
max-width: 90vw;
} }
.page-container { .page-container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 0 1rem;
} }
.page-header { .page-header {
margin-bottom: 1rem; margin-bottom: 2rem;
border-radius: 4px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.spacer { .spacer {
@ -256,11 +254,12 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #f5f5f5; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px; border-radius: 12px;
padding: 3rem; padding: 4rem 2rem;
margin: 2rem 0; margin: 2rem 0;
text-align: center; text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
.empty-icon { .empty-icon {
@ -271,33 +270,86 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.empty-state h2 {
color: #424242;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #757575;
}
.task-container { .task-container {
margin: 1rem 0; margin: 1rem 0;
} }
.task-panel { .task-panel {
margin-bottom: 1rem; margin-bottom: 1.5rem;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
min-height: 80px;
}
.task-panel:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.task-panel .mat-expansion-panel-header {
height: auto;
min-height: 80px;
padding: 1rem 1.5rem;
} }
.task-title-container { .task-title-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.75rem;
flex: 1;
min-width: 0;
margin-right: 1rem;
} }
.comment-badge { .task-title-container mat-icon {
margin-left: 0.5rem; color: #1976d2;
flex-shrink: 0;
}
.task-title-container span {
font-weight: 500;
font-size: 1.1rem;
flex: 1;
min-width: 0;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
} }
.task-info { .task-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
gap: 0.25rem;
flex-shrink: 0;
min-width: 180px;
text-align: right;
}
.task-info-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.comment-badge {
flex-shrink: 0;
} }
.task-author { .task-author {
font-weight: 500; font-weight: 500;
color: #424242;
} }
.task-timestamp { .task-timestamp {
@ -306,18 +358,23 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
} }
.task-details { .task-details {
padding: 1rem 0; padding: 1.5rem 0;
} }
.task-description { .task-description {
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.6;
margin-bottom: 1.5rem; margin-bottom: 2rem;
white-space: pre-line; white-space: pre-line;
color: #424242;
background-color: #f8f9fa;
padding: 1rem;
border-radius: 8px;
border-left: 4px solid #1976d2;
} }
.comments-section { .comments-section {
margin-top: 1.5rem; margin-top: 2rem;
} }
.comments-section h3 { .comments-section h3 {
@ -325,28 +382,42 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 1.2rem; font-size: 1.2rem;
margin-bottom: 1rem; margin-bottom: 1.5rem;
color: #424242;
}
.comments-section h3 mat-icon {
color: #1976d2;
} }
.comments-list { .comments-list {
margin-bottom: 1.5rem; margin-bottom: 2rem;
} }
.comment-item { .comment-item {
padding: 1rem; padding: 1.25rem;
background-color: #f5f5f5; background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 8px; border-radius: 12px;
margin-bottom: 1rem; margin-bottom: 1rem;
border: 1px solid #e0e0e0;
transition: transform 0.2s ease;
}
.comment-item:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
} }
.comment-header { .comment-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.5rem; align-items: center;
margin-bottom: 0.75rem;
} }
.comment-author { .comment-author {
font-weight: 500; font-weight: 600;
color: #1976d2;
} }
.comment-time { .comment-time {
@ -357,38 +428,56 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
.comment-content { .comment-content {
margin: 0; margin: 0;
white-space: pre-line; white-space: pre-line;
color: #424242;
line-height: 1.5;
} }
.no-comments { .no-comments {
font-style: italic; font-style: italic;
color: #757575; color: #757575;
text-align: center; text-align: center;
padding: 1rem; padding: 2rem;
background-color: #f5f5f5; background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
border-radius: 8px; border-radius: 12px;
border: 2px dashed #e0e0e0;
} }
.comment-form { .comment-form {
background-color: #f9f9f9; background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 8px; border-radius: 12px;
padding: 1rem; padding: 1.5rem;
margin-top: 1rem; margin-top: 1.5rem;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
} }
.comment-form h4 { .comment-form h4 {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1.5rem;
font-size: 1rem; font-size: 1.1rem;
color: #424242;
display: flex;
align-items: center;
gap: 0.5rem;
}
.comment-form h4::before {
content: '💬';
} }
.form-row { .form-row {
margin-bottom: 1rem; margin-bottom: 1.5rem;
} }
.form-actions { .form-actions {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
}
.form-actions mat-form-field {
min-width: 200px;
} }
.full-width { .full-width {
@ -400,51 +489,54 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-radius: 8px;
font-weight: 500;
} }
.button-spinner { .button-spinner {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
@media (max-width: 768px) {
.page-container {
padding: 0 0.5rem 5rem 0.5rem;
}
.task-info {
align-items: flex-start;
}
.form-actions {
flex-direction: column;
}
.form-actions mat-form-field {
width: 100%;
}
}
`, `,
], ],
}) })
export class TaskListComponent implements OnInit { export class TaskListComponent {
private tasksService = inject(TasksService); private readonly tasksService = inject(TasksService);
private usersService = inject(UsersService); private readonly usersService = inject(UsersService);
private commentsService = inject(CommentsService); private readonly commentsService = inject(CommentsService);
private fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private toastService = inject(ToastService); private readonly toastService = inject(ToastService);
tasks = signal<TaskResponse[]>([]);
users = signal<User[]>([]);
comments = signal<CommentResponse[]>([]);
loading = signal(true);
addingComment = signal(false);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
openAddTaskDialog() { readonly tasks = signal<TaskResponse[]>([]);
const dialogRef = this.dialog.open(AddTaskDialogComponent, { readonly users = signal<User[]>([]);
width: '400px', readonly comments = signal<CommentResponse[]>([]);
enterAnimationDuration: '220ms', readonly loading = signal(true);
exitAnimationDuration: '180ms', readonly addingComment = signal(false);
autoFocus: true,
restoreFocus: true,
disableClose: false,
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.loadData();
}
});
}
commentForm: FormGroup = this.fb.group({ readonly commentForm: FormGroup = this.fb.group({
content: ['', Validators.required], content: ['', Validators.required],
userId: ['', Validators.required], userId: ['', Validators.required],
}); });
ngOnInit() { constructor() {
this.loadData(); this.loadData();
} }
@ -476,6 +568,22 @@ export class TaskListComponent implements OnInit {
}); });
} }
openAddTaskDialog() {
const dialogRef = this.dialog.open(AddTaskDialogComponent, {
width: '400px',
enterAnimationDuration: '220ms',
exitAnimationDuration: '180ms',
autoFocus: true,
restoreFocus: true,
disableClose: false,
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.loadData();
}
});
}
getUserName(userId: number): string { getUserName(userId: number): string {
const user = this.users().find((u) => u.id === userId); const user = this.users().find((u) => u.id === userId);
return user ? user.name : 'Unknown User'; return user ? user.name : 'Unknown User';
@ -483,7 +591,7 @@ export class TaskListComponent implements OnInit {
getTaskComments(taskId: number): CommentResponse[] { getTaskComments(taskId: number): CommentResponse[] {
return this.comments().filter( return this.comments().filter(
(comment) => comment.comments.task_id === taskId (comment) => comment.comments.taskId === taskId
); );
} }
@ -523,8 +631,8 @@ export class TaskListComponent implements OnInit {
const comment = { const comment = {
content: this.commentForm.value.content, content: this.commentForm.value.content,
task_id: taskId, taskId: taskId,
user_id: parseInt(this.commentForm.value.userId), userId: parseInt(this.commentForm.value.userId),
}; };
this.commentsService this.commentsService

View File

@ -1,5 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@ -17,7 +23,6 @@ import { TaskFormComponent } from '@org/web/tasks';
@Component({ @Component({
selector: 'lib-user-details', selector: 'lib-user-details',
standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
RouterModule, RouterModule,
@ -29,41 +34,77 @@ import { TaskFormComponent } from '@org/web/tasks';
MatToolbarModule, MatToolbarModule,
TaskFormComponent, TaskFormComponent,
], ],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<mat-toolbar color="primary">User Details</mat-toolbar> <mat-toolbar color="primary">
<span>User Details</span>
</mat-toolbar>
@if (loading()) { @if (loading()) {
<div class="loading-container"> <div class="loading-container">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner> <mat-progress-spinner
mode="indeterminate"
diameter="40"
></mat-progress-spinner>
<p>Loading user details...</p>
</div> </div>
} @else if (user()) { } @else if (user()) {
<div class="user-container">
<mat-card> <mat-card>
<mat-card-header> <mat-card-header>
<mat-icon aria-hidden="true">account_circle</mat-icon>
<div>
<mat-card-title>{{ user()?.name }}</mat-card-title> <mat-card-title>{{ user()?.name }}</mat-card-title>
<mat-card-subtitle>{{ user()?.email }}</mat-card-subtitle> <mat-card-subtitle>{{ user()?.email }}</mat-card-subtitle>
</div>
</mat-card-header> </mat-card-header>
<mat-card-content>
<p><strong>User ID:</strong> {{ user()?.id }}</p>
<p><strong>Tasks:</strong> {{ userTasks().length }}</p>
</mat-card-content>
<mat-card-actions> <mat-card-actions>
<button mat-button routerLink="/users">Back to Users</button> <button
mat-raised-button
color="primary"
routerLink="/users"
class="back-button"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
<span>Back to Users</span>
</button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
<div class="tasks-section"> <div class="tasks-section">
<h2>User Tasks</h2> <h2>
<mat-icon aria-hidden="true">assignment</mat-icon>
<span>User Tasks</span>
</h2>
@if (userTasks().length) { @if (userTasks().length) {
<mat-list> <mat-list>
@for (task of userTasks(); track task.tasks.id) { @for (task of userTasks(); track task.tasks.id) {
<mat-list-item> <mat-list-item>
<mat-icon matListItemIcon>assignment</mat-icon> <mat-icon
<h3 matListItemTitle>{{ task.tasks.title }}</h3> matListItemIcon
<p matListItemLine> [style.color]="'#3f51b5'"
aria-hidden="true"
>task_alt</mat-icon
>
<div matListItemTitle>
<h3 class="task-title">{{ task.tasks.title }}</h3>
</div>
<div matListItemLine class="task-description">
{{ task.tasks.description || 'No description' }} {{ task.tasks.description || 'No description' }}
</p> </div>
</mat-list-item> </mat-list-item>
} }
</mat-list> </mat-list>
} @else { } @else {
<div class="no-tasks">
<mat-icon aria-hidden="true">assignment_late</mat-icon>
<p>No tasks found for this user.</p> <p>No tasks found for this user.</p>
</div>
} }
<lib-task-form <lib-task-form
@ -71,15 +112,42 @@ import { TaskFormComponent } from '@org/web/tasks';
(taskAdded)="loadUserTasks()" (taskAdded)="loadUserTasks()"
></lib-task-form> ></lib-task-form>
</div> </div>
</div>
} @else { } @else {
<div class="error-container"> <div class="error-container">
<mat-icon
style="font-size: 3rem; height: 3rem; width: 3rem; color: #f44336;"
aria-hidden="true"
>error_outline</mat-icon
>
<p>User not found</p> <p>User not found</p>
<button mat-button routerLink="/users">Back to Users</button> <button
mat-raised-button
color="primary"
routerLink="/users"
class="back-button"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
<span>Back to Users</span>
</button>
</div> </div>
} }
`, `,
styles: [ styles: [
` `
:host {
display: block;
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
@media (min-width: 576px) {
:host {
padding: 0 1.5rem;
}
}
.loading-container, .loading-container,
.error-container { .error-container {
display: flex; display: flex;
@ -89,31 +157,238 @@ import { TaskFormComponent } from '@org/web/tasks';
margin: 2rem 0; margin: 2rem 0;
} }
@media (min-width: 576px) {
.loading-container,
.error-container {
margin: 3rem 0;
}
}
.error-container {
text-align: center;
}
.error-container p {
color: #f44336;
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
mat-toolbar {
margin-bottom: 1.5rem;
border-radius: 4px;
}
@media (min-width: 576px) {
mat-toolbar {
margin-bottom: 2rem;
border-radius: 8px;
}
}
.user-container {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.user-container {
gap: 2rem;
grid-template-columns: 1fr 2fr;
}
}
mat-card { mat-card {
margin: 1rem 0; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 1rem;
height: fit-content;
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
mat-card-header {
margin-bottom: 1.25rem;
flex-direction: column;
align-items: flex-start;
}
@media (min-width: 576px) {
mat-card-header {
margin-bottom: 1.5rem;
flex-direction: row;
align-items: center;
}
}
mat-card-title {
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 0.25rem;
word-break: break-word;
}
@media (min-width: 576px) {
mat-card-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
}
mat-card-subtitle {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
word-break: break-word;
}
@media (min-width: 576px) {
mat-card-subtitle {
font-size: 1rem;
}
} }
.tasks-section { .tasks-section {
background-color: #f9f9f9;
border-radius: 8px;
padding: 1rem;
}
@media (min-width: 576px) {
.tasks-section {
padding: 1.5rem;
}
}
.tasks-section h2 {
font-size: 1.25rem;
margin-top: 0;
margin-bottom: 1rem;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 576px) {
.tasks-section h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
}
}
mat-list {
border-radius: 4px;
overflow: hidden;
background-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
mat-list-item {
margin-bottom: 0.5rem;
border-left: 4px solid #3f51b5;
transition: transform 0.2s, box-shadow 0.2s;
height: auto !important;
min-height: 48px;
padding: 8px 0;
}
mat-list-item:hover {
transform: translateX(2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
[matListItemTitle] {
white-space: normal;
word-break: break-word;
}
[matListItemLine] {
white-space: normal;
word-break: break-word;
overflow: visible;
text-overflow: unset;
display: block;
max-height: none;
line-height: 1.5;
margin-top: 4px;
}
.task-title {
margin: 0;
font-weight: 500;
font-size: 1rem;
}
.task-description {
white-space: pre-line;
color: rgba(0, 0, 0, 0.6);
padding-top: 4px;
}
.no-tasks {
text-align: center;
padding: 1.5rem;
color: rgba(0, 0, 0, 0.5);
background-color: white;
border-radius: 8px;
}
@media (min-width: 576px) {
.no-tasks {
padding: 2rem;
}
}
lib-task-form {
display: block;
margin-top: 1.5rem;
padding: 1rem;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
@media (min-width: 576px) {
lib-task-form {
margin-top: 2rem; margin-top: 2rem;
padding: 1.5rem;
}
}
.back-button {
margin-top: 1rem;
width: 100%;
}
@media (min-width: 576px) {
.back-button {
width: auto;
}
} }
`, `,
], ],
}) })
export class UserDetailsComponent implements OnInit { export class UserDetailsComponent {
private route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private usersService = inject(UsersService); private readonly usersService = inject(UsersService);
private tasksService = inject(TasksService); private readonly tasksService = inject(TasksService);
user = signal<User | null>(null); readonly user = signal<User | null>(null);
userTasks = signal<TaskResponse[]>([]); readonly userTasks = signal<TaskResponse[]>([]);
loading = signal(true); readonly loading = signal(true);
ngOnInit() { constructor() {
effect(() => {
this.route.params.subscribe((params) => { this.route.params.subscribe((params) => {
const userId = +params['id']; const userId = +params['id'];
this.loadUserDetails(userId); this.loadUserDetails(userId);
this.loadUserTasks(userId); this.loadUserTasks(userId);
}); });
});
} }
loadUserDetails(userId: number) { loadUserDetails(userId: number) {
@ -136,7 +411,7 @@ export class UserDetailsComponent implements OnInit {
this.tasksService.getTasks().subscribe({ this.tasksService.getTasks().subscribe({
next: (tasks) => { next: (tasks) => {
this.userTasks.set(tasks.filter((task) => task.tasks.user_id === id)); this.userTasks.set(tasks.filter((task) => task.tasks.userId === id));
}, },
error: (error) => console.error('Error fetching tasks:', error), error: (error) => console.error('Error fetching tasks:', error),
}); });

View File

@ -1,7 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, effect, inject, signal } from '@angular/core'; import {
import { MatDialog } from '@angular/material/dialog'; ChangeDetectionStrategy,
import { AddUserDialogComponent } from './add-user-dialog.component'; Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { import {
FormBuilder, FormBuilder,
FormControl, FormControl,
@ -11,6 +16,7 @@ import {
} from '@angular/forms'; } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@ -19,15 +25,19 @@ import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { CommentStoreService, TasksStoreService, UserStoreService } from '@org/shared/stores';
import { ToastService } from '@org/shared/toast';
import { CommentResponse, TaskResponse, User } from '@org/shared/api'; import { CommentResponse, TaskResponse, User } from '@org/shared/api';
import {
CommentStoreService,
TasksStoreService,
UserStoreService,
} from '@org/shared/stores';
import { ToastService } from '@org/shared/toast';
import { AddUserDialogComponent } from './add-user-dialog.component';
type UserWithCounts = User & { taskCount: number; commentCount: number }; type UserWithCounts = User & { taskCount: number; commentCount: number };
@Component({ @Component({
selector: 'lib-user-list', selector: 'lib-user-list',
standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
MatFormFieldModule, MatFormFieldModule,
@ -42,8 +52,8 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
MatCardModule, MatCardModule,
MatDividerModule, MatDividerModule,
], ],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<mat-toolbar color="primary">User Management</mat-toolbar>
<div class="container"> <div class="container">
<div class="search-container"> <div class="search-container">
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
@ -65,13 +75,13 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
<div class="loading-container"> <div class="loading-container">
<mat-progress-spinner mode="indeterminate" /> <mat-progress-spinner mode="indeterminate" />
</div> </div>
} @else if (filteredUsers.length === 0) { } @else if (filteredUsers().length === 0) {
<div class="empty-state"> <div class="empty-state">
<p>No users found. Try adjusting your search or add a new user.</p> <p>No users found. Try adjusting your search or add a new user.</p>
</div> </div>
} @else { } @else {
<div class="user-grid"> <div class="user-grid">
@for (user of filteredUsers; track user.id) { @for (user of filteredUsers(); track user.id) {
<mat-card class="user-card"> <mat-card class="user-card">
<button <button
mat-icon-button mat-icon-button
@ -83,11 +93,9 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
</button> </button>
<mat-card-header> <mat-card-header>
<mat-card-title> <mat-card-title>
<a <a [routerLink]="['/users', user.id]" class="user-link">{{
[routerLink]="['/users', user.id]" user.name
class="user-link" }}</a>
>{{ user.name }}</a
>
</mat-card-title> </mat-card-title>
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle> <mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
</mat-card-header> </mat-card-header>
@ -118,7 +126,7 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
margin-bottom: 1rem; margin-bottom: 1rem;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center; align-items: baseline;
} }
.full-width { .full-width {
width: 100%; width: 100%;
@ -266,49 +274,50 @@ export class UserListComponent {
private readonly commentsStore = inject(CommentStoreService); private readonly commentsStore = inject(CommentStoreService);
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
protected searchControl = new FormControl('');
protected isLoading = signal(true);
filteredUsers: UserWithCounts[] = [];
private allUsersWithCounts: (User & { taskCount: number; commentCount: number })[] = [];
constructor() {
this.userStore.getUsers();
effect(() => {
const users = this.userStore.users();
const tasks = this.taskStore.tasks?.() ?? [];
const comments = this.commentsStore.comments?.() ?? [];
if (Array.isArray(users)) {
this.allUsersWithCounts = addCountsToUsers(users, tasks, comments);
this.isLoading.set(false);
this.applyFilter();
}
});
this.searchControl.valueChanges.subscribe(() => this.applyFilter());
}
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
userForm: FormGroup = this.fb.group({ readonly searchControl = new FormControl('');
name: ['', Validators.required], readonly isLoading = signal(true);
email: ['', [Validators.required, Validators.email]],
});
applyFilter() { private readonly allUsersWithCounts = signal<UserWithCounts[]>([]);
readonly filteredUsers = computed(() => {
const term = this.searchControl.value?.toLocaleLowerCase() ?? ''; const term = this.searchControl.value?.toLocaleLowerCase() ?? '';
this.filteredUsers = this.allUsersWithCounts return this.allUsersWithCounts().filter(
.filter(
(u) => (u) =>
u.name?.toLocaleLowerCase().includes(term) || u.name?.toLocaleLowerCase().includes(term) ||
u.email?.toLowerCase().includes(term) u.email?.toLowerCase().includes(term)
); );
});
readonly userForm: FormGroup = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
constructor() {
this.userStore.getUsers();
this.taskStore.getTasks();
this.commentsStore.getComments();
effect(() => {
const users = this.userStore.users();
const tasks = this.taskStore.tasks();
const comments = this.commentsStore.comments();
if (Array.isArray(users)) {
this.allUsersWithCounts.set(addCountsToUsers(users, tasks, comments));
this.isLoading.set(false);
}
});
effect(() => {
this.searchControl.valueChanges.subscribe();
});
} }
deleteUser(id: number) { deleteUser(id: number) {
if (confirm('Are you sure you want to delete this user?')) { if (confirm('Are you sure you want to delete this user?')) {
this.userStore.deleteUser(id); this.userStore.deleteUser(id);
this.toastService.show('User deleted successfully'); this.toastService.show('User deleted successfully');
this.applyFilter();
} }
} }
@ -325,20 +334,24 @@ export class UserListComponent {
if (result) { if (result) {
this.userStore.createUser(result); this.userStore.createUser(result);
this.toastService.show('User created successfully'); this.toastService.show('User created successfully');
this.applyFilter();
} }
}); });
} }
} }
function addCountsToUsers(users: User[], tasks: TaskResponse[], comments: CommentResponse[]) { function addCountsToUsers(
users: User[],
tasks: TaskResponse[],
comments: CommentResponse[]
) {
return users.map((user) => { return users.map((user) => {
const userTasks = tasks.filter((t) => t.tasks.user_id === user.id); const userTasks = tasks.filter((t) => t.tasks.userId === user.id);
const userComments = comments.filter((c) => c.comments.user_id === user.id); const userComments = comments.filter((c) => c.comments.userId === user.id);
return { return {
...user, ...user,
taskCount: userTasks.length, taskCount: userTasks.length,
commentCount: userComments.length, commentCount: userComments.length,
}; };
}) });
} }

View File

@ -8,7 +8,8 @@ export const getTasks = async (c: Context) => {
const tasks = await db const tasks = await db
.select() .select()
.from(tasksTable) .from(tasksTable)
.leftJoin(usersTable, eq(tasksTable.userId, usersTable.id)); .leftJoin(usersTable, eq(tasksTable.userId, usersTable.id))
.orderBy(tasksTable.timestamp);
if (tasks.length === 0) return c.json({ error: "No tasks found" }, 404); if (tasks.length === 0) return c.json({ error: "No tasks found" }, 404);

Binary file not shown.

View File

@ -3,7 +3,6 @@ import "dotenv/config";
import { db } from "./index"; import { db } from "./index";
import { commentsTable, tasksTable, usersTable } from "./schema"; import { commentsTable, tasksTable, usersTable } from "./schema";
// Configuration
const NUM_USERS = 10; const NUM_USERS = 10;
const TASKS_PER_USER_MIN = 1; const TASKS_PER_USER_MIN = 1;
const TASKS_PER_USER_MAX = 5; const TASKS_PER_USER_MAX = 5;