Skip to content

Commit 81303ac

Browse files
committed
add break time entries and simplified time tracker ui
1 parent 4fb18f3 commit 81303ac

134 files changed

Lines changed: 6792 additions & 438 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/launch.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"version": "0.0.1",
3+
"configurations": [
4+
{
5+
"name": "solidtime",
6+
"runtimeExecutable": "bash",
7+
"runtimeArgs": ["-c", "vendor/bin/sail up -d && while true; do sleep 3600; done"],
8+
"port": 8083,
9+
"autoPort": false,
10+
"url": "https://solidtime.test"
11+
},
12+
{
13+
"name": "sso",
14+
"runtimeExecutable": "bash",
15+
"runtimeArgs": ["-c", "node .claude/sso-preview-proxy.cjs"],
16+
"port": 8099,
17+
"autoPort": false,
18+
"url": "http://localhost:8099/login"
19+
}
20+
]
21+
}

.claude/sso-preview-proxy.cjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Transparent same-origin proxy so the preview browser can load the SSO app
2+
// (which is served at sso.solidtime.test with absolute asset URLs) under
3+
// http://localhost:8099. Rewrites absolute app URLs -> localhost so assets,
4+
// XHR and redirects stay same-origin and don't need .test DNS resolution.
5+
const http = require('http');
6+
7+
const UPSTREAM_HOST = '127.0.0.1';
8+
const UPSTREAM_PORT = 8084; // SSO container's direct host port (FORWARD_WEB_PORT)
9+
const APP_HOST = 'sso.solidtime.test';
10+
const LISTEN = 8099;
11+
const SELF = 'http://localhost:' + LISTEN;
12+
13+
const rewrite = (s) =>
14+
s
15+
.split('https://' + APP_HOST)
16+
.join(SELF)
17+
.split('http://' + APP_HOST)
18+
.join(SELF);
19+
20+
http.createServer((req, res) => {
21+
const headers = { ...req.headers, host: APP_HOST, 'accept-encoding': 'identity' };
22+
const upstream = http.request(
23+
{ host: UPSTREAM_HOST, port: UPSTREAM_PORT, path: req.url, method: req.method, headers },
24+
(ur) => {
25+
const ct = String(ur.headers['content-type'] || '');
26+
const isText = /text|html|json|javascript|css|xml/i.test(ct);
27+
const outHeaders = { ...ur.headers };
28+
delete outHeaders['content-length'];
29+
// make cookies usable on http://localhost
30+
if (outHeaders['set-cookie']) {
31+
outHeaders['set-cookie'] = []
32+
.concat(outHeaders['set-cookie'])
33+
.map((c) => c.replace(/;\s*Secure/gi, '').replace(/;\s*Domain=[^;]+/gi, ''));
34+
}
35+
if (outHeaders.location) outHeaders.location = rewrite(String(outHeaders.location));
36+
if (!isText) {
37+
res.writeHead(ur.statusCode, ur.headers);
38+
ur.pipe(res);
39+
return;
40+
}
41+
const chunks = [];
42+
ur.on('data', (c) => chunks.push(c));
43+
ur.on('end', () => {
44+
const body = rewrite(Buffer.concat(chunks).toString('utf8'));
45+
res.writeHead(ur.statusCode, outHeaders);
46+
res.end(body);
47+
});
48+
}
49+
);
50+
upstream.on('error', (e) => {
51+
res.writeHead(502);
52+
res.end('proxy error: ' + e.message);
53+
});
54+
req.pipe(upstream);
55+
}).listen(LISTEN, () =>
56+
console.log('SSO preview proxy on ' + SELF + ' -> ' + UPSTREAM_HOST + ':' + UPSTREAM_PORT)
57+
);

.zed/settings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Folder-specific settings
2+
//
3+
// For a full list of overridable settings, and general information on folder-specific settings,
4+
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
5+
{
6+
7+
"file_scan_exclusions": [
8+
".claude/*"
9+
]
10+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands;
6+
7+
use App\Enums\Role;
8+
use App\Models\Member;
9+
use App\Models\Organization;
10+
use App\Models\User;
11+
use App\Service\ColorService;
12+
use Illuminate\Console\Command;
13+
use Illuminate\Support\Carbon;
14+
use Illuminate\Support\Facades\DB;
15+
use Illuminate\Support\Str;
16+
17+
/**
18+
* Reproduction seeder for GitHub issue #1138 ("Performance with several clients or projects").
19+
*
20+
* The web app loads the full clients/projects/tasks lists into the browser via
21+
* fetchAllPages() (see resources/js/utils/useProjectsQuery.ts etc.), paging through the
22+
* API 15 records at a time. With hundreds of clients and thousands of projects this means
23+
* a long burst of sequential requests and a huge client-side dataset that the Manage > Projects,
24+
* Manage > Clients and the time tracker project/client/task pickers then render and filter
25+
* in-browser — which is what degrades.
26+
*
27+
* This command builds a logged-in-able owner with that data volume so the behaviour can be
28+
* observed locally. Rows are inserted with the query builder (bypassing model events and
29+
* auditing) so the large volume seeds in seconds without polluting the audits table.
30+
*/
31+
class SeedPerformanceProjects extends Command
32+
{
33+
protected $signature = 'seed:perf-projects
34+
{--clients=500 : Number of clients to create}
35+
{--projects=2500 : Number of projects to create}
36+
{--tasks-per-project=2 : Number of tasks to create per project (0 to skip)}
37+
{--email=perf-projects@test.test : Email for the test user}
38+
{--password=password : Password for the test user}';
39+
40+
protected $description = 'Create an owner with many clients/projects/tasks to reproduce the client/project list performance issue (GH #1138)';
41+
42+
public function handle(ColorService $colorService): int
43+
{
44+
$clientCount = max(0, (int) $this->option('clients'));
45+
$projectCount = max(0, (int) $this->option('projects'));
46+
$tasksPerProject = max(0, (int) $this->option('tasks-per-project'));
47+
$email = (string) $this->option('email');
48+
$password = (string) $this->option('password');
49+
50+
if (User::where('email', $email)->exists()) {
51+
$this->error("User with email {$email} already exists. Delete it first or pass a different --email.");
52+
53+
return self::FAILURE;
54+
}
55+
56+
if ($projectCount > 0 && $clientCount === 0) {
57+
$this->warn('No clients requested — all projects will be created without a client.');
58+
}
59+
60+
$this->info("Creating performance test user: {$email}");
61+
62+
// Owner sees every project in the organization (projects:view:all), so all seeded
63+
// projects load regardless of visibility — no ProjectMember rows needed.
64+
$user = User::factory()->withPersonalOrganization()->create([
65+
'name' => 'Projects Perf Tester',
66+
'email' => $email,
67+
'password' => bcrypt($password),
68+
]);
69+
70+
$organization = Organization::factory()->withOwner($user)->create([
71+
'name' => 'Perf Projects Org',
72+
'personal_team' => false,
73+
'currency' => 'EUR',
74+
]);
75+
76+
Member::factory()
77+
->forUser($user)
78+
->forOrganization($organization)
79+
->role(Role::Owner)
80+
->create();
81+
82+
$user->current_team_id = $organization->id;
83+
$user->save();
84+
85+
$organizationId = $organization->getKey();
86+
$faker = fake();
87+
$now = Carbon::now();
88+
89+
// --- Clients -------------------------------------------------------------------
90+
$clientIds = [];
91+
if ($clientCount > 0) {
92+
$this->info("Creating {$clientCount} clients...");
93+
$bar = $this->output->createProgressBar($clientCount);
94+
$rows = [];
95+
for ($i = 0; $i < $clientCount; $i++) {
96+
$id = (string) Str::uuid();
97+
$clientIds[] = $id;
98+
// Distinct, descending created_at keeps list ordering stable across pages.
99+
$createdAt = $now->copy()->subSeconds($clientCount - $i)->toDateTimeString();
100+
$rows[] = [
101+
'id' => $id,
102+
'name' => sprintf('Client %04d - %s', $i + 1, $faker->company()),
103+
'organization_id' => $organizationId,
104+
'created_at' => $createdAt,
105+
'updated_at' => $createdAt,
106+
];
107+
$bar->advance();
108+
}
109+
foreach (array_chunk($rows, 1000) as $chunk) {
110+
DB::table('clients')->insert($chunk);
111+
}
112+
$bar->finish();
113+
$this->newLine();
114+
}
115+
116+
// --- Projects ------------------------------------------------------------------
117+
$projectIds = [];
118+
if ($projectCount > 0) {
119+
$this->info("Creating {$projectCount} projects...");
120+
$bar = $this->output->createProgressBar($projectCount);
121+
$suffixes = ['Website', 'Mobile App', 'Platform', 'Migration', 'Redesign', 'Integration', 'Rollout', 'Dashboard', 'API', 'Audit'];
122+
$rows = [];
123+
for ($i = 0; $i < $projectCount; $i++) {
124+
$id = (string) Str::uuid();
125+
$projectIds[] = $id;
126+
// ~10% of projects have no client; the rest are spread round-robin over the clients.
127+
$client = ($clientCount === 0 || $i % 10 === 0)
128+
? null
129+
: $clientIds[$i % $clientCount];
130+
$billable = $i % 2 === 0;
131+
$createdAt = $now->copy()->subSeconds($projectCount - $i)->toDateTimeString();
132+
$rows[] = [
133+
'id' => $id,
134+
'name' => sprintf('Project %04d - %s %s', $i + 1, $faker->company(), $faker->randomElement($suffixes)),
135+
'color' => $colorService->getRandomColor(),
136+
'billable_rate' => $billable ? $faker->numberBetween(50, 1000) * 100 : null,
137+
'is_public' => true,
138+
'is_billable' => $billable,
139+
'client_id' => $client,
140+
'organization_id' => $organizationId,
141+
'created_at' => $createdAt,
142+
'updated_at' => $createdAt,
143+
];
144+
$bar->advance();
145+
}
146+
foreach (array_chunk($rows, 1000) as $chunk) {
147+
DB::table('projects')->insert($chunk);
148+
}
149+
$bar->finish();
150+
$this->newLine();
151+
}
152+
153+
// --- Tasks ---------------------------------------------------------------------
154+
$taskCount = 0;
155+
if ($tasksPerProject > 0 && $projectCount > 0) {
156+
$taskCount = $projectCount * $tasksPerProject;
157+
$this->info("Creating {$taskCount} tasks ({$tasksPerProject} per project)...");
158+
$bar = $this->output->createProgressBar($taskCount);
159+
$taskNames = ['Design', 'Development', 'Testing', 'Code Review', 'Deployment', 'Planning', 'Research', 'Documentation', 'Bugfix', 'Meeting'];
160+
$rows = [];
161+
$createdAt = $now->toDateTimeString();
162+
foreach ($projectIds as $projectId) {
163+
for ($t = 0; $t < $tasksPerProject; $t++) {
164+
$rows[] = [
165+
'id' => (string) Str::uuid(),
166+
'name' => $taskNames[$t % count($taskNames)],
167+
'project_id' => $projectId,
168+
'organization_id' => $organizationId,
169+
'created_at' => $createdAt,
170+
'updated_at' => $createdAt,
171+
];
172+
$bar->advance();
173+
if (count($rows) >= 1000) {
174+
DB::table('tasks')->insert($rows);
175+
$rows = [];
176+
}
177+
}
178+
}
179+
if ($rows !== []) {
180+
DB::table('tasks')->insert($rows);
181+
}
182+
$bar->finish();
183+
$this->newLine();
184+
}
185+
186+
$this->newLine();
187+
$this->info('Done! Log in to reproduce GH #1138:');
188+
$this->info(" Email: {$email}");
189+
$this->info(" Password: {$password}");
190+
$this->info(" Clients: {$clientCount}");
191+
$this->info(" Projects: {$projectCount}");
192+
$this->info(" Tasks: {$taskCount}");
193+
$this->newLine();
194+
$this->line('Then open Manage > Projects, Manage > Clients, or the time tracker project/client/task pickers.');
195+
196+
return self::SUCCESS;
197+
}
198+
}

0 commit comments

Comments
 (0)