Compare commits

...

10 commits

86 changed files with 1471 additions and 9690 deletions

View file

@ -13,7 +13,6 @@ APP_MAINTENANCE_DRIVER=file
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
@ -27,7 +26,7 @@ DB_DATABASE=incr
DB_USERNAME=incr_user
DB_PASSWORD=change_me_in_production
SESSION_DRIVER=database
SESSION_DRIVER=cookie
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_PATH=/

View file

@ -4,13 +4,13 @@ on:
push:
branches: ['release/*']
pull_request:
branches: [main]
branches: [main, 'release/*']
jobs:
ci:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-1
services:
db:
@ -24,17 +24,10 @@ jobs:
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up PHP
uses: https://github.com/shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_mysql, mbstring, xml, dom, bcmath, gd, exif, pcntl
coverage: pcov
- name: Cache Composer dependencies
uses: https://data.forgejo.org/actions/cache@v4
with:
path: ~/.composer/cache
path: ~/.cache/composer
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
@ -58,6 +51,9 @@ jobs:
- name: Lint
run: vendor/bin/pint --test
- name: Static analysis
run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress --error-format=github
- name: Tests
run: php artisan test --coverage-clover coverage.xml --coverage-text
@ -131,7 +127,7 @@ jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
image: node:22-bookworm-slim
steps:
- uses: https://data.forgejo.org/actions/checkout@v4

View file

@ -0,0 +1,44 @@
name: Build and Push CI Image
on:
push:
branches: [main]
paths:
- 'docker/build/**'
- '.forgejo/workflows/images.yml'
workflow_dispatch:
jobs:
images:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
strategy:
matrix:
include:
- name: incr-ci
file: docker/build/Dockerfile.ci
version: php8.3-1
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ matrix.version }}
forge.lvl0.xyz/lvl0/${{ matrix.name }}:latest
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ github.sha }}

View file

@ -1,45 +0,0 @@
name: linter
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
- name: Lint Frontend
run: npm run lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'

View file

@ -1,50 +0,0 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Node Dependencies
run: npm ci
- name: Build Assets
run: npm run build
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Tests
run: ./vendor/bin/phpunit

View file

@ -1,3 +0,0 @@
resources/js/components/ui/*
resources/js/ziggy.js
resources/views/mail/*

View file

@ -1,19 +0,0 @@
{
"semi": true,
"singleQuote": true,
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"printWidth": 150,
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx", "cn"],
"tailwindStylesheet": "resources/css/app.css",
"tabWidth": 4,
"overrides": [
{
"files": "**/*.yml",
"options": {
"tabWidth": 2
}
}
]
}

63
Jenkinsfile vendored
View file

@ -1,63 +0,0 @@
pipeline {
agent any
triggers {
GenericTrigger(
causeString: 'Triggered on tag push',
token: 'tag-trigger-secret',
printContributedVariables: true,
printPostContent: true,
regexpFilterExpression: 'ref=refs/tags/.*\nafter=(?!0{40}).*',
regexpFilterText: '$ref\n$after',
genericVariables: [
[key: 'ref', value: '$.ref'],
[key: 'after', value: '$.after']
]
)
}
environment {
REGISTRY = 'codeberg.org'
IMAGE_NAME = "${REGISTRY}/lvl0/incr"
DOCKER_CREDENTIALS_ID = 'codeberg-registry'
}
stages {
stage('Tag Push Filter') {
steps {
script {
if (!env.ref?.startsWith('refs/tags/')) {
echo "Not a tag push (ref = ${env.ref}). Skipping build."
currentBuild.result = 'NOT_BUILT'
return
}
}
}
}
stage('Build & Push Docker Image') {
when {
expression {
return env.ref?.startsWith('refs/tags/') && env.after != null && env.after != "0000000000000000000000000000000000000000"
}
}
steps {
script {
def tagName = env.ref.replaceFirst(/^refs\/tags\//, '')
def cleanedTag = tagName.replaceFirst(/^v/, '')
sh "docker build -t $IMAGE_NAME:$cleanedTag -f docker/Dockerfile ."
withCredentials([usernamePassword(credentialsId: "$DOCKER_CREDENTIALS_ID", usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {
sh """
echo "$PASSWORD" | docker login $REGISTRY -u "$USERNAME" --password-stdin
docker push $IMAGE_NAME:$cleanedTag
docker tag $IMAGE_NAME:$cleanedTag $IMAGE_NAME:latest
docker push $IMAGE_NAME:latest
"""
}
}
}
}
}
}

View file

@ -2,35 +2,34 @@
# incr
**A minimalist counter with milestone-driven progress**
**A minimalist counter**
*Track anything you accumulate — with visual progress and milestone reinforcement*
*Track anything you accumulate — click to increment*
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Laravel](https://img.shields.io/badge/Laravel-12-FF2D20?logo=laravel&logoColor=white)](https://laravel.com/)
[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://reactjs.org/)
[![Laravel](https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white)](https://laravel.com/)
[![Livewire](https://img.shields.io/badge/Livewire-4-FB70A9?logo=livewire&logoColor=white)](https://livewire.laravel.com/)
</div>
## About
incr is a minimalist, self-hosted tracking app. Pick something you want to accumulate — shares, books, workouts, anything — log your progress, and watch milestones fall.
incr is a minimalist, self-hosted counter. Pick something you want to accumulate — books, workouts, anything — set a starting value, then click the number to count up.
It features a distinctive LED-style digital display, configurable milestones, and optional price tracking.
It features a distinctive LED-style digital display and nothing else.
## Features
- **LED-style display** — large red digital counter
- **Milestone tracking** — set targets, cycle through them as you progress
- **Purchase logging** — add entries with date and optional price
- **Price tracking** — optional; log a current price to see portfolio value and P/L
- **Click to increment** — click the number to add to it
- **Set a value directly** — for when you need to add more than one
- **Self-hosted** — your data stays on your server
## Tech Stack
- **Backend**: Laravel 12 (PHP 8.3+) with MySQL
- **Frontend**: React 19 + TypeScript with Inertia.js
- **Styling**: Tailwind CSS 4 with shadcn/ui components
- **Backend**: Laravel 13 (PHP 8.3+) with MySQL
- **Frontend**: Livewire 4 with Blade
- **Styling**: Tailwind CSS 4
- **Deployment**: Docker / Podman with multi-stage builds
## Self-hosting
@ -101,7 +100,9 @@ ## Development
dev-up
```
Available commands inside the shell: `dev-up`, `dev-down`, `dev-rebuild`, `dev-logs`, `dev-shell`, `dev-artisan`, `dev-composer`.
Available commands inside the shell: `dev-up`, `dev-down`, `dev-restart`, `dev-rebuild`, `dev-logs`, `dev-logs-db`, `dev-shell`, `dev-artisan`, `dev-composer`, `base-build`.
The dev stack binds host ports 8000, 5173 and 3307. Only one project using these can run at a time.
## License

View file

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AssetController extends Controller
{
public function index(): JsonResponse
{
return response()->json(Asset::orderBy('symbol')->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'symbol' => 'required|string|max:10|unique:assets,symbol',
'full_name' => 'nullable|string|max:255',
]);
$asset = Asset::create([
'symbol' => strtoupper($validated['symbol']),
'full_name' => $validated['full_name'],
]);
return response()->json([
'success' => true,
'message' => 'Asset created successfully!',
'asset' => $asset,
], 201);
}
public function show(Asset $asset): JsonResponse
{
$asset->load('assetPrices');
return response()->json([
'asset' => $asset,
'current_price' => $asset->currentPrice(),
]);
}
public function search(Request $request): JsonResponse
{
$query = $request->get('q');
if (! $query) {
return response()->json([]);
}
$assets = Asset::where('symbol', 'like', "%{$query}%")
->orWhere('full_name', 'like', "%{$query}%")
->orderBy('symbol')
->limit(10)
->get();
return response()->json($assets);
}
}

View file

@ -1,60 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Show the registration page.
*/
public function create(): Response
{
if (User::exists()) {
abort(403, 'Registration is disabled.');
}
return Inertia::render('auth/register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
if (User::exists()) {
abort(403, 'Registration is disabled.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::forceCreate([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
event(new Registered($user));
Auth::login($user);
return redirect()->intended(route('dashboard', absolute: false));
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Milestones;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MilestoneController extends Controller
{
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'target' => 'required|integer|min:1',
'description' => 'required|string|max:255',
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
}
$tracker->milestones()->create($validated);
return back()->with('success', 'Milestone created successfully');
}
public function index(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json([]);
}
return response()->json($tracker->milestones()->orderBy('target')->get());
}
}

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Pricing;
use App\Http\Controllers\Controller;
use App\Models\Pricing\AssetPrice;
use App\Models\Tracker;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PricingController extends Controller
{
private ?Tracker $tracker;
public function __construct()
{
$this->tracker = User::default()->tracker;
}
public function current(): JsonResponse
{
return response()->json([
'current_price' => AssetPrice::current($this->tracker?->asset_id),
]);
}
public function update(Request $request)
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'price' => 'required|numeric|min:0.0001',
]);
if (! $this->tracker?->asset_id) {
return back()->withErrors(['asset' => 'Please set an asset first.']);
}
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
if (! $this->tracker->price_tracking_enabled) {
$this->tracker->update(['price_tracking_enabled' => true]);
}
return back()->with('success', 'Asset price updated successfully!');
}
public function history(Request $request): JsonResponse
{
$limit = min(max(1, $request->integer('limit', 30)), 365);
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
}
public function forDate(Request $request, string $date): JsonResponse
{
validator(['date' => $date], ['date' => 'required|date_format:Y-m-d'])->validate();
return response()->json([
'date' => $date,
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
]);
}
}

View file

@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TrackerController extends Controller
{
public function show(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(['exists' => false]);
}
return response()->json(['exists' => true, 'tracker' => $tracker->load('asset')]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'label' => 'sometimes|string|max:255',
'unit' => 'sometimes|string|max:50',
'price_tracking_enabled' => 'boolean',
'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$user = User::default();
if ($user->tracker) {
return response()->json(['error' => 'Tracker already exists.'], 409);
}
$assetId = null;
if (! empty($validated['symbol'])) {
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
$assetId = $asset->id;
}
$tracker = $user->tracker()->create([
'label' => $validated['label'] ?? 'Counter',
'unit' => $validated['unit'] ?? 'units',
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false,
'asset_id' => $assetId,
]);
return response()->json($tracker->load('asset'), 201);
}
public function update(Request $request): RedirectResponse|JsonResponse
{
$validated = $request->validate([
'label' => 'sometimes|string|max:255',
'unit' => 'sometimes|string|max:50',
'price_tracking_enabled' => 'sometimes|boolean',
'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return back()->withErrors(['tracker' => 'No tracker found.']);
}
if (array_key_exists('symbol', $validated)) {
if ($validated['symbol']) {
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
$tracker->asset_id = $asset->id;
} else {
$tracker->asset_id = null;
}
}
$update = [];
if (isset($validated['label'])) {
$update['label'] = $validated['label'];
}
if (isset($validated['unit'])) {
$update['unit'] = $validated['unit'];
}
if (array_key_exists('price_tracking_enabled', $validated)) {
$update['price_tracking_enabled'] = $validated['price_tracking_enabled'];
}
if (array_key_exists('symbol', $validated)) {
$update['asset_id'] = $tracker->asset_id;
}
$tracker->update($update);
return back();
}
}

View file

@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use App\Models\Transactions\Entry;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EntryController extends Controller
{
public function index(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json([]);
}
return response()->json($tracker->entries()->orderBy('date', 'desc')->get());
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'quantity' => 'required|numeric|min:0.000001',
'unit_price' => 'nullable|numeric|min:0.01',
'total_cost' => 'nullable|numeric|min:0.01',
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
}
// If unit_price and total_cost provided, verify the calculation
if (isset($validated['unit_price'], $validated['total_cost'])) {
$calculatedTotal = $validated['quantity'] * $validated['unit_price'];
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
return back()->withErrors([
'total_cost' => 'Total cost does not match quantity × unit price.',
]);
}
}
$tracker->entries()->create($validated);
return back()->with('success', 'Entry added successfully!');
}
public function summary(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json([
'total_quantity' => 0,
'total_cost' => 0,
'average_cost_per_unit' => 0,
]);
}
return response()->json([
'total_quantity' => Entry::totalQuantity($tracker->id),
'total_cost' => Entry::totalCost($tracker->id),
'average_cost_per_unit' => Entry::averageCostPerUnit($tracker->id),
]);
}
public function destroy(Entry $entry): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker || $entry->tracker_id !== $tracker->id) {
return response()->json(['error' => 'Entry not found.'], 404);
}
$entry->delete();
return response()->json([
'success' => true,
'message' => 'Entry deleted successfully!',
]);
}
}

View file

@ -1,23 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View file

@ -1,56 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
return [
...parent::share($request),
'name' => config('app.name'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user()?->only(['id', 'name', 'email']),
],
'ziggy' => fn (): array => [
...(new Ziggy)->toArray(),
'location' => $request->url(),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}

View file

@ -1,86 +0,0 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

101
app/Livewire/Counter.php Normal file
View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Models\Tracker;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Counter extends Component
{
// Ceiling of the unsigned int column backing trackers.count.
public const MAX_COUNT = 4294967295;
#[Locked]
public int $count = 0;
#[Locked]
public bool $needsOnboarding = false;
public bool $editing = false;
#[Validate('required|integer|min:0|max:'.self::MAX_COUNT)]
public ?int $value = null;
public function mount(): void
{
$tracker = Tracker::current();
$this->needsOnboarding = $tracker === null;
$this->count = $tracker === null ? 0 : $tracker->count;
$this->value = $this->count;
}
public function initialise(): void
{
$this->validate();
$tracker = Tracker::current() ?? Tracker::create([
'label' => 'Counter',
'unit' => 'units',
'count' => $this->value,
]);
$this->count = $tracker->count;
$this->needsOnboarding = false;
}
public function increment(): void
{
$tracker = Tracker::current();
if (! $tracker || $tracker->count >= self::MAX_COUNT) {
return;
}
$tracker->increment('count');
$tracker->refresh();
$this->count = $tracker->count;
$this->value = $this->count;
}
public function edit(): void
{
$this->value = $this->count;
$this->resetValidation();
$this->editing = true;
}
public function save(): void
{
$this->validate();
$tracker = Tracker::current();
if (! $tracker) {
return;
}
$tracker->update(['count' => $this->value]);
$this->count = (int) $this->value;
$this->editing = false;
}
public function cancel(): void
{
$this->value = $this->count;
$this->resetValidation();
$this->editing = false;
}
public function render(): View
{
return view('livewire.counter')->layout('layouts.app');
}
}

View file

@ -1,63 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @method static create(array $array)
* @method static where(string $string, string $value)
* @method static find(int $id)
* @method static orderBy(string $string)
*
* @property int $id
* @property string $symbol
* @property string|null $full_name
*/
class Asset extends Model
{
use HasFactory;
protected $fillable = [
'symbol',
'full_name',
];
protected $casts = [
'symbol' => 'string',
'full_name' => 'string',
];
public function assetPrices(): HasMany
{
return $this->hasMany(Pricing\AssetPrice::class);
}
public function currentPrice(): ?float
{
$latestPrice = $this->assetPrices()->latest('date')->first();
return $latestPrice ? $latestPrice->price : null;
}
public static function findBySymbol(string $symbol): ?self
{
return static::where('symbol', strtoupper($symbol))->first();
}
public static function findOrCreateBySymbol(string $symbol, ?string $fullName = null): self
{
$asset = static::findBySymbol($symbol);
if (! $asset) {
$asset = static::create([
'symbol' => strtoupper($symbol),
'full_name' => $fullName,
]);
}
return $asset;
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @method static create(array $array)
* @method static orderBy(string $string)
*/
class Milestone extends Model
{
protected $fillable = [
'tracker_id',
'target',
'description',
];
protected $casts = [
'target' => 'integer',
];
public function tracker(): BelongsTo
{
return $this->belongsTo(Tracker::class);
}
}

View file

@ -1,86 +0,0 @@
<?php
namespace App\Models\Pricing;
use App\Models\Asset;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @method static latest(string $string)
* @method static where(string $string, string $string1, string $date)
* @method static updateOrCreate(string[] $array, float[] $array1)
* @method static orderBy(string $string, string $string1)
*
* @property Carbon $date
* @property float $price
*/
class AssetPrice extends Model
{
use HasFactory;
protected $fillable = [
'asset_id',
'date',
'price',
];
protected $casts = [
'date' => 'date',
'price' => 'decimal:4',
];
public function asset(): BelongsTo
{
return $this->belongsTo(Asset::class);
}
public static function current(?int $assetId = null): ?float
{
$query = static::latest('date');
if ($assetId) {
$query->where('asset_id', $assetId);
}
$latestPrice = $query->first();
return $latestPrice ? $latestPrice->price : null;
}
public static function forDate(string $date, ?int $assetId = null): ?float
{
$query = static::where('date', '<=', $date)
->orderBy('date', 'desc');
if ($assetId) {
$query->where('asset_id', $assetId);
}
$price = $query->first();
return $price ? $price->price : null;
}
public static function updatePrice(int $assetId, string $date, float $price): self
{
return static::updateOrCreate(
['asset_id' => $assetId, 'date' => $date],
['price' => $price]
);
}
public static function history(?int $assetId = null, int $limit = 30): Collection
{
$query = static::orderBy('date', 'desc')->limit($limit);
if ($assetId) {
$query->where('asset_id', $assetId);
}
return $query->get();
}
}

View file

@ -4,45 +4,30 @@
namespace App\Models;
use App\Models\Transactions\Entry;
use Database\Factories\TrackerFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tracker extends Model
{
/** @use HasFactory<TrackerFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'asset_id',
'label',
'unit',
'price_tracking_enabled',
'count',
];
protected function casts(): array
{
return [
'price_tracking_enabled' => 'boolean',
'count' => 'integer',
];
}
public function user(): BelongsTo
public static function current(): ?self
{
return $this->belongsTo(User::class);
}
public function asset(): BelongsTo
{
return $this->belongsTo(Asset::class);
}
public function entries(): HasMany
{
return $this->hasMany(Entry::class);
}
public function milestones(): HasMany
{
return $this->hasMany(Milestone::class);
return self::orderBy('id')->first();
}
}

View file

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Transactions;
use App\Models\Tracker;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Entry extends Model
{
protected $fillable = [
'tracker_id',
'date',
'quantity',
'unit_price',
'total_cost',
];
protected function casts(): array
{
return [
'date' => 'date',
'quantity' => 'decimal:6',
'unit_price' => 'decimal:4',
'total_cost' => 'decimal:2',
];
}
public function tracker(): BelongsTo
{
return $this->belongsTo(Tracker::class);
}
public static function totalQuantity(int $trackerId): float
{
return (float) static::where('tracker_id', $trackerId)->sum('quantity');
}
public static function totalCost(int $trackerId): float
{
return (float) static::where('tracker_id', $trackerId)->sum('total_cost');
}
public static function averageCostPerUnit(int $trackerId): float
{
$totalQuantity = static::totalQuantity($trackerId);
$totalCost = static::totalCost($trackerId);
return $totalQuantity > 0 ? $totalCost / $totalQuantity : 0;
}
}

View file

@ -1,64 +0,0 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function tracker(): HasOne
{
return $this->hasOne(Tracker::class);
}
public static function default(): self
{
return self::firstWhere('email', 'user@incr.local')
?? self::forceCreate([
'email' => 'user@incr.local',
'name' => 'Default User',
'password' => bcrypt(Str::random(32)),
]);
}
public function hasCompletedOnboarding(): bool
{
return $this->hasEntries() && $this->hasMilestones();
}
public function hasEntries(): bool
{
return (bool) $this->tracker?->entries()->exists();
}
public function hasMilestones(): bool
{
return (bool) $this->tracker?->milestones()->exists();
}
}

View file

@ -1,7 +1,5 @@
<?php
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -14,11 +12,7 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
})

View file

@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -1,8 +1,8 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/react-starter-kit",
"name": "lvl0/incr",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"description": "A minimalist counter.",
"keywords": [
"laravel",
"framework"
@ -10,18 +10,19 @@
"license": "MIT",
"require": {
"php": "^8.2",
"inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"tightenco/ziggy": "^2.4"
"livewire/livewire": "^4.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.5",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.43",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpstan/phpstan": "^2.1.32 <2.2",
"phpunit/phpunit": "^12.0"
},
"autoload": {
@ -56,11 +57,6 @@
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"dev:ssr": [
"npm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"

405
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a30f9c47c86600aafedfd3862ac984c5",
"content-hash": "9b1957cddc5253df62939093e476f2f8",
"packages": [
{
"name": "brick/math",
@ -1053,79 +1053,6 @@
],
"time": "2025-08-22T14:27:06+00:00"
},
{
"name": "inertiajs/inertia-laravel",
"version": "v3.0.6",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c255b1ea050cf563b240542a76f7f756ccdb2d67",
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^11.0|^12.0|^13.0",
"php": "^8.2.0",
"symfony/console": "^7.0|^8.0"
},
"conflict": {
"laravel/boost": "<2.2.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.2",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.16",
"mockery/mockery": "^1.3.3",
"orchestra/testbench": "^9.2|^10.0|^11.0",
"phpunit/phpunit": "^11.5|^12.0",
"roave/security-advisories": "dev-master"
},
"suggest": {
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Inertia\\ServiceProvider"
]
}
},
"autoload": {
"files": [
"./helpers.php"
],
"psr-4": {
"Inertia\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Reinink",
"email": "jonathan@reinink.ca",
"homepage": "https://reinink.ca"
}
],
"description": "The Laravel adapter for Inertia.js.",
"keywords": [
"inertia",
"laravel"
],
"support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.0.6"
},
"time": "2026-04-10T14:29:45+00:00"
},
{
"name": "laravel/framework",
"version": "v13.7.0",
@ -2098,6 +2025,82 @@
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "livewire/livewire",
"version": "v4.4.0",
"source": {
"type": "git",
"url": "https://github.com/livewire/livewire.git",
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/514b29d5a23594d4e4846494f580268b20c2f11e",
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e",
"shasum": ""
},
"require": {
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"illuminate/validation": "^10.0|^11.0|^12.0|^13.0",
"laravel/prompts": "^0.1.24|^0.2|^0.3",
"league/mime-type-detection": "^1.9",
"php": "^8.1",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-kernel": "^6.2|^7.0|^8.0"
},
"require-dev": {
"calebporzio/sushi": "^2.1",
"laravel/framework": "^10.15.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.3.1",
"orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0",
"orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0",
"phpunit/phpunit": "^10.4|^11.5|^12.5",
"psy/psysh": "^0.11.22|^0.12"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Livewire": "Livewire\\Livewire"
},
"providers": [
"Livewire\\LivewireServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Livewire\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "A front-end framework for Laravel.",
"support": {
"issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v4.4.0"
},
"funding": [
{
"url": "https://github.com/livewire",
"type": "github"
}
],
"time": "2026-08-10T15:24:22+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@ -5954,76 +5957,6 @@
],
"time": "2026-03-30T13:44:50+00:00"
},
{
"name": "tightenco/ziggy",
"version": "v2.6.2",
"source": {
"type": "git",
"url": "https://github.com/tighten/ziggy.git",
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tighten/ziggy/zipball/8a0b645921623f77dceaf543d61ecd51a391d96e",
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": ">=9.0",
"php": ">=8.1"
},
"require-dev": {
"laravel/folio": "^1.1",
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0",
"pestphp/pest": "^2.0 || ^3.0 || ^4.0",
"pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Tighten\\Ziggy\\ZiggyServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Tighten\\Ziggy\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Daniel Coulbourne",
"email": "daniel@tighten.co"
},
{
"name": "Jake Bathman",
"email": "jake@tighten.co"
},
{
"name": "Jacob Baker-Kretzmar",
"email": "jacob@tighten.co"
}
],
"description": "Use your Laravel named routes in JavaScript.",
"homepage": "https://github.com/tighten/ziggy",
"keywords": [
"Ziggy",
"javascript",
"laravel",
"routes"
],
"support": {
"issues": "https://github.com/tighten/ziggy/issues",
"source": "https://github.com/tighten/ziggy/tree/v2.6.2"
},
"time": "2026-03-05T14:41:19+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
@ -6424,6 +6357,137 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "iamcal/sql-parser",
"version": "v0.7",
"source": {
"type": "git",
"url": "https://github.com/iamcal/SQLParser.git",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
"shasum": ""
},
"require-dev": {
"php-coveralls/php-coveralls": "^1.0",
"phpunit/phpunit": "^5|^6|^7|^8|^9"
},
"type": "library",
"autoload": {
"psr-4": {
"iamcal\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cal Henderson",
"email": "cal@iamcal.com"
}
],
"description": "MySQL schema parser",
"support": {
"issues": "https://github.com/iamcal/SQLParser/issues",
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
},
"time": "2026-01-28T22:20:33+00:00"
},
{
"name": "larastan/larastan",
"version": "v3.9.6",
"source": {
"type": "git",
"url": "https://github.com/larastan/larastan.git",
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
"shasum": ""
},
"require": {
"ext-json": "*",
"iamcal/sql-parser": "^0.7.0",
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
"php": "^8.2",
"phpstan/phpstan": "^2.1.44"
},
"require-dev": {
"doctrine/coding-standard": "^13",
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
"mockery/mockery": "^1.6.12",
"nikic/php-parser": "^5.4",
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
},
"suggest": {
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Larastan\\Larastan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Can Vural",
"email": "can9119@gmail.com"
}
],
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
"keywords": [
"PHPStan",
"code analyse",
"code analysis",
"larastan",
"laravel",
"package",
"php",
"static analysis"
],
"support": {
"issues": "https://github.com/larastan/larastan/issues",
"source": "https://github.com/larastan/larastan/tree/v3.9.6"
},
"funding": [
{
"url": "https://github.com/canvural",
"type": "github"
}
],
"time": "2026-04-16T10:02:43+00:00"
},
{
"name": "laravel/pail",
"version": "v1.2.6",
@ -6992,6 +7056,59 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.1.56",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/93a603c9fc3be8c3c93bbc8d22170ad766685537",
"reference": "93a603c9fc3be8c3c93bbc8d22170ad766685537",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-05-26T17:04:57+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "12.5.6",

View file

@ -1,117 +0,0 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the amount of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View file

@ -1,55 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Server Side Rendering
|--------------------------------------------------------------------------
|
| These options configures if and how Inertia uses Server Side Rendering
| to pre-render each initial request made to your application's pages
| so that server rendered HTML is delivered for the user's browser.
|
| See: https://inertiajs.com/server-side-rendering
|
*/
'ssr' => [
'enabled' => true,
'url' => 'http://127.0.0.1:13714',
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
],
/*
|--------------------------------------------------------------------------
| Testing
|--------------------------------------------------------------------------
|
| The values described here are used to locate Inertia components on the
| filesystem. For instance, when using `assertInertia`, the assertion
| attempts to locate the component as a file relative to the paths.
|
*/
'testing' => [
'ensure_pages_exist' => true,
'page_paths' => [
resource_path('js/pages'),
],
'page_extensions' => [
'js',
'jsx',
'svelte',
'ts',
'tsx',
'vue',
],
],
];

View file

@ -18,7 +18,7 @@
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
'driver' => env('SESSION_DRIVER', 'cookie'),
/*
|--------------------------------------------------------------------------

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Tracker;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Tracker>
*/
class TrackerFactory extends Factory
{
protected $model = Tracker::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'label' => 'Counter',
'unit' => 'units',
'count' => 0,
];
}
}

View file

@ -1,45 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('milestones');
}
public function down(): void
{
Schema::create('milestones', function (Blueprint $table) {
$table->id();
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
$table->integer('target');
$table->string('description');
$table->timestamps();
});
}
};

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('trackers', function (Blueprint $table): void {
$table->unsignedInteger('count')->default(0)->after('unit');
});
if (Schema::hasTable('entries')) {
DB::table('trackers')->orderBy('id')->each(function (object $tracker): void {
$total = (float) DB::table('entries')->where('tracker_id', $tracker->id)->sum('quantity');
DB::table('trackers')
->where('id', $tracker->id)
->update(['count' => max(0, (int) round($total))]);
});
}
Schema::dropIfExists('entries');
}
// Lossy by design: per-entry dates and prices cannot be rebuilt from a scalar.
public function down(): void
{
Schema::create('entries', function (Blueprint $table): void {
$table->id();
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->decimal('quantity', 12, 6);
$table->decimal('unit_price', 12, 4)->nullable();
$table->decimal('total_cost', 12, 2)->nullable();
$table->timestamps();
$table->index(['tracker_id', 'date']);
});
DB::table('trackers')->where('count', '>', 0)->orderBy('id')->each(function (object $tracker): void {
DB::table('entries')->insert([
'tracker_id' => $tracker->id,
'date' => now()->toDateString(),
'quantity' => $tracker->count,
'created_at' => now(),
'updated_at' => now(),
]);
});
Schema::table('trackers', function (Blueprint $table): void {
$table->dropColumn('count');
});
}
};

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('trackers', function (Blueprint $table): void {
$table->dropForeign(['asset_id']);
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
});
Schema::dropIfExists('asset_prices');
Schema::dropIfExists('assets');
}
// Lossy by design: recorded symbols and prices cannot be recovered.
public function down(): void
{
Schema::create('assets', function (Blueprint $table): void {
$table->id();
$table->string('symbol')->unique();
$table->string('full_name')->nullable();
$table->timestamps();
$table->index('symbol');
});
Schema::create('asset_prices', function (Blueprint $table): void {
$table->id();
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->decimal('price', 10, 4);
$table->timestamps();
$table->unique(['asset_id', 'date']);
$table->index('asset_id');
$table->index('date');
});
Schema::table('trackers', function (Blueprint $table): void {
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('price_tracking_enabled')->default(false);
});
}
};

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('trackers', function (Blueprint $table): void {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
}
// Lossy by design: the app has no users, so nothing is restored into these tables.
public function down(): void
{
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table): void {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
// Nullable, unlike the original: existing trackers have no user to point at,
// so a NOT NULL foreign key cannot be added back.
Schema::table('trackers', function (Blueprint $table): void {
$table->foreignId('user_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
}
};

View file

@ -2,8 +2,7 @@
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use App\Models\Tracker;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
@ -13,11 +12,10 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
Tracker::firstOrCreate([], [
'label' => 'Counter',
'unit' => 'units',
'count' => 0,
]);
}
}

View file

@ -0,0 +1,35 @@
# Image for CI: PHP, Composer and the extensions the suite needs. No runtime
# server; the frontend build runs in its own job on a node image.
#
# Published as incr-ci:php<version>-<revision>, not :latest. Runners cache
# mutable tags and will not re-pull them, so bump the revision in the tag and
# in ci.yml whenever this file changes.
#
# Debian-based rather than alpine: ffr hit repeated DNS resolution timeouts
# against codeload.github.com on the alpine build.
#
# pdo_mysql, not sqlite: the migration tests query information_schema and
# exercise MySQL-specific DDL, so CI runs against a real mysql:8.0 service.
FROM php:8.3-cli
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions \
pdo_mysql \
mbstring \
dom \
xml \
bcmath \
fileinfo \
pcntl \
gd \
pcov
# netcat-openbsd backs the MySQL readiness check in ci.yml.
# nodejs is not used by the app's tests; the Forgejo JavaScript actions
# (checkout, cache) are executed with it inside this container.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip nodejs netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

View file

@ -1,4 +1,4 @@
# Multi-stage build for Laravel + React application
# Multi-stage build for Laravel + Livewire application
FROM node:20-alpine AS frontend-builder
WORKDIR /app
@ -13,9 +13,6 @@ RUN npm ci
COPY resources/ resources/
COPY public/ public/
COPY vite.config.ts ./
COPY tsconfig.json ./
COPY components.json ./
COPY eslint.config.js ./
# Build frontend assets
RUN npm run build

View file

@ -1,44 +0,0 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import typescript from 'typescript-eslint';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...typescript.configs.recommended,
{
...react.configs.flat.recommended,
...react.configs.flat['jsx-runtime'], // Required for React 17+
languageOptions: {
globals: {
...globals.browser,
},
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/no-unescaped-entities': 'off',
},
settings: {
react: {
version: 'detect',
},
},
},
{
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
{
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
},
prettier, // Turn off all rules that might conflict with Prettier
];

6309
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,57 +3,12 @@
"type": "module",
"scripts": {
"build": "vite build",
"build:ssr": "vite build && vite build --ssr",
"dev": "vite",
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix",
"types": "tsc --noEmit"
"dev": "vite"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.13.5",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"typescript-eslint": "^8.23.0"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^3.0.3",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.0.6",
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^6.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"globals": "^15.14.0",
"laravel-vite-plugin": "^3.1.0",
"lucide-react": "^1.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3",
"vite": "^8.0.10"
},
"optionalDependencies": {

21
phpstan.neon Normal file
View file

@ -0,0 +1,21 @@
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 7
paths:
- app/
- database/
- tests/
excludePaths:
- bootstrap/*.php
- storage/*
ignoreErrors:
# Migration files return an anonymous class; the base Migration declares neither up() nor down().
# An interface on the migration is not an option: it would live under autoload-dev, which
# production omits (`composer install --no-dev`), so every migration would fatal on deploy.
-
message: '#Call to an undefined method Illuminate\\Database\\Migrations\\Migration::(up|down)\(\)#'
path: tests/Feature/*MigrationTest.php

View file

@ -1,9 +1,6 @@
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@font-face {
font-family: '7Segment';
@ -24,159 +21,3 @@ .glow-red {
.glow-red:hover {
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
}
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono-display:
'Major Mono Display', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.87 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.87 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.985 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -1,24 +0,0 @@
import '../css/app.css';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { initializeTheme } from './hooks/use-appearance';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({
title: (title) => title ? `${title} - ${appName}` : appName,
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
setup({ el, App, props }) {
const root = createRoot(el);
root.render(<App {...props} />);
},
progress: {
color: '#4B5563',
},
});
// This will set light / dark mode on load...
initializeTheme();

View file

@ -1,55 +0,0 @@
import AddEntryForm from '@/components/Transactions/AddEntryForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import { cn } from '@/lib/utils';
type FormType = 'purchase' | 'milestone';
interface InlineFormProps {
type: FormType | null;
unit?: string;
onClose: () => void;
onSuccess?: (type: FormType) => void;
className?: string;
}
export default function InlineForm({
type,
unit = 'units',
onClose,
onSuccess,
className,
}: InlineFormProps) {
if (!type) return null;
const handleSuccess = () => {
if (onSuccess) onSuccess(type);
onClose();
};
return (
<div
className={cn(
'bg-black p-8',
'transition-all duration-300',
className,
)}
>
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
<div className="flex justify-center">
{type === 'purchase' ? (
<AddEntryForm
unit={unit}
onSuccess={handleSuccess}
onCancel={onClose}
/>
) : (
<AddMilestoneForm
onSuccess={handleSuccess}
onCancel={onClose}
/>
)}
</div>
</div>
</div>
);
}

View file

@ -1,53 +0,0 @@
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
interface LedDisplayProps {
value: number;
className?: string;
animate?: boolean;
onClick?: () => void;
}
export default function LedDisplay({
value,
className,
onClick
}: LedDisplayProps) {
const [displayValue, setDisplayValue] = useState(0);
// Animate number changes
useEffect(() => {
setDisplayValue(value);
return;
}, [value]);
const formattedValue = Math.floor(displayValue).toString();
return (
<div
className={cn(
"w-full text-center select-none cursor-pointer",
"bg-black text-red-500",
"px-8 py-12 transition-all duration-300",
className
)}
onClick={onClick}
>
<div className="relative w-full flex items-center justify-center">
<div className={cn(
"relative z-10",
"text-[8rem] md:text-[12rem] lg:text-[16rem]",
"font-digital font-normal",
"text-red-500",
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
"filter brightness-110",
"leading-none",
"transition-all duration-300"
)}
style={{ letterSpacing: '0.15em' }}>
{formattedValue}
</div>
</div>
</div>
);
}

View file

@ -1,62 +0,0 @@
import { cn } from '@/lib/utils';
import type { Milestone } from '@/types/domain';
interface ProgressBarProps {
currentQuantity: number;
milestones: Milestone[];
selectedMilestoneIndex?: number;
className?: string;
onClick?: () => void;
}
export default function ProgressBar({
currentQuantity,
milestones,
selectedMilestoneIndex = 0,
className,
onClick
}: ProgressBarProps) {
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
? milestones[selectedMilestoneIndex]
: null;
const progressPercentage = selectedMilestone
? Math.min((currentQuantity / selectedMilestone.target) * 100, 100)
: 0;
return (
<div
className={cn(
"bg-black cursor-pointer",
"transition-all duration-300",
"p-8",
className
)}
onClick={onClick}
>
{/* Progress Bar Container */}
<div className="w-full">
{/* Old-school progress bar with overlaid text */}
<div className="w-full border-4 border-red-500 p-2 bg-black relative overflow-hidden glow-red">
{/* Inner container */}
<div className="relative h-8">
{/* Progress fill */}
<div
className="absolute top-0 left-0 h-full bg-red-500 transition-all duration-500 ease-out"
style={{ width: `${progressPercentage}%` }}
/>
{/* Text overlay */}
{selectedMilestone && (
<div className="relative h-full flex items-center justify-center">
{/* Base text (red on black background) */}
<div className="text-red-500 font-mono text-sm font-bold mix-blend-difference relative z-10">
{progressPercentage.toFixed(1)}%
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,143 +0,0 @@
import { cn } from '@/lib/utils';
import { Plus, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
import type { Milestone } from '@/types/domain';
interface StatsBoxProps {
stats: {
totalShares: number;
};
unit?: string;
milestones?: Milestone[];
selectedMilestoneIndex?: number;
onMilestoneSelect?: (index: number) => void;
className?: string;
onAddPurchase?: () => void;
onAddMilestone?: () => void;
}
export default function StatsBox({
stats,
unit = 'units',
milestones = [],
selectedMilestoneIndex = 0,
onMilestoneSelect,
className,
onAddPurchase,
onAddMilestone,
}: StatsBoxProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const handleCycleMilestone = () => {
if (milestones.length === 0 || !onMilestoneSelect) return;
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
onMilestoneSelect(nextIndex);
};
return (
<div
className={cn(
"bg-black p-8",
"transition-all duration-300",
className
)}
>
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
<div className="flex justify-between items-center mb-6 relative">
<ComponentTitle>Stats</ComponentTitle>
<div className="flex items-center space-x-2 relative">
{/* Action Dropdown */}
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
aria-label="Add actions"
>
<Plus className="w-4 h-4" />
</button>
{isDropdownOpen && (
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
{onAddPurchase && (
<button
onClick={() => {
onAddPurchase();
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
>
ADD ENTRY
</button>
)}
{onAddMilestone && (
<button
onClick={() => {
onAddMilestone();
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
>
ADD MILESTONE
</button>
)}
</div>
)}
</div>
{/* Milestone Cycle Button */}
{milestones.length > 1 && (
<button
onClick={handleCycleMilestone}
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
aria-label="Cycle milestone"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Milestone Table */}
<div className="pt-4">
<div className="text-red-500 underline font-bold mb-3 font-mono">MILESTONES</div>
<div className="overflow-x-auto">
<table className="w-full text-sm font-mono">
<thead>
<tr>
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
<th className="text-right text-red-500 text-xs py-2">{unit.toUpperCase()}</th>
</tr>
</thead>
<tbody>
<tr className="text-red-500 font-bold">
<td className="py-1 pr-4">CURRENT</td>
<td className="text-right py-1">
{Math.floor(stats.totalShares).toLocaleString()}
</td>
</tr>
{milestones.map((milestone, index) => (
<tr
key={index}
className={cn(
index === selectedMilestoneIndex
? "bg-red-500 text-black"
: "text-red-500 font-bold"
)}
>
<td className="py-1 pr-4">{milestone.description}</td>
<td className="text-right py-1">
{Math.floor(milestone.target).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,10 +0,0 @@
import { cn } from '@/lib/utils';
import { type HTMLAttributes } from 'react';
export default function InputError({ message, className = '', ...props }: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
return message ? (
<p {...props} className={cn('text-sm text-red-600 dark:text-red-400', className)}>
{message}
</p>
) : null;
}

View file

@ -1,97 +0,0 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface MilestoneFormData {
target: string;
description: string;
[key: string]: string;
}
interface AddMilestoneFormProps {
onSuccess?: () => void;
onCancel?: () => void;
}
export default function AddMilestoneForm({ onSuccess, onCancel }: AddMilestoneFormProps) {
const { data, setData, post, processing, errors, reset } = useForm<MilestoneFormData>({
target: '',
description: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('milestones.store'), {
onSuccess: () => {
reset();
if (onSuccess) {
onSuccess();
}
},
});
};
return (
<div className="w-full">
<div className="space-y-4">
<ComponentTitle>ADD MILESTONE</ComponentTitle>
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="target" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; Target Number</Label>
<Input
id="target"
type="number"
step="1"
min="1"
placeholder="1500"
value={data.target}
onChange={(e) => setData('target', e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
/>
<InputError message={errors.target} />
</div>
<div>
<Label htmlFor="description" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; Description</Label>
<Input
id="description"
type="text"
placeholder="First milestone"
value={data.description}
onChange={(e) => setData('description', e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
/>
<InputError message={errors.description} />
</div>
<div className="flex gap-3 pt-2">
<Button
type="submit"
disabled={processing}
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
[EXECUTE]
</Button>
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
[ABORT]
</Button>
)}
</div>
</form>
</div>
</div>
);
}

View file

@ -1,113 +0,0 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useState } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface CreateTrackerStepProps {
onSuccess: () => void;
}
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
const [label, setLabel] = useState('');
const [unit, setUnit] = useState('');
const [processing, setProcessing] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const submit: FormEventHandler = async (e) => {
e.preventDefault();
setProcessing(true);
setErrors({});
try {
const response = await fetch(route('tracker.store'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
'Accept': 'application/json',
},
body: JSON.stringify({
label,
unit,
price_tracking_enabled: 0,
}),
});
if (response.ok || response.status === 201 || response.status === 409) {
onSuccess();
} else {
const data = await response.json();
if (data.errors) {
setErrors(data.errors);
} else if (data.message) {
setErrors({ label: data.message });
}
}
} catch {
setErrors({ label: 'Something went wrong. Please try again.' });
} finally {
setProcessing(false);
}
};
return (
<div className="w-full">
<div className="space-y-4">
<ComponentTitle>SET UP YOUR TRACKER</ComponentTitle>
<p className="text-sm text-red-400/60 font-mono">
[SYSTEM] What are you tracking?
</p>
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="label" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Tracker Name
</Label>
<Input
id="label"
type="text"
placeholder="My Portfolio"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
[REQUIRED] e.g. "My Portfolio", "Books Read", "KM Run"
</p>
<InputError message={errors.label} />
</div>
<div>
<Label htmlFor="unit" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Unit
</Label>
<Input
id="unit"
type="text"
placeholder="shares"
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
[REQUIRED] e.g. "shares", "books", "km"
</p>
<InputError message={errors.unit} />
</div>
<Button
type="submit"
disabled={processing || !label || !unit}
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
[INITIALIZE]
</Button>
</form>
</div>
</div>
);
}

View file

@ -1,104 +0,0 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { todayISO } from '@/lib/utils';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useState } from 'react';
interface OnboardingFlowProps {
onComplete?: () => void;
}
function csrfToken(): string {
return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '';
}
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [startingValue, setStartingValue] = useState('0');
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>();
const submit: FormEventHandler = async (e) => {
e.preventDefault();
setProcessing(true);
setError(undefined);
const headers = {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
Accept: 'application/json',
};
try {
const trackerResponse = await fetch(route('tracker.store'), {
method: 'POST',
headers,
body: JSON.stringify({}),
});
if (!trackerResponse.ok && trackerResponse.status !== 409) {
setError('Could not create the counter. Please try again.');
return;
}
const quantity = Number(startingValue);
if (quantity > 0) {
const entryResponse = await fetch(route('entries.store'), {
method: 'POST',
headers,
body: JSON.stringify({ date: todayISO(), quantity }),
});
if (!entryResponse.ok) {
setError('Could not save the starting value. Please try again.');
return;
}
}
onComplete?.();
} catch {
setError('Something went wrong. Please try again.');
} finally {
setProcessing(false);
}
};
return (
<div className="min-h-screen bg-black flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
<form onSubmit={submit} className="space-y-4">
<Label
htmlFor="starting-value"
className="text-red-400 font-mono text-xs uppercase tracking-wider"
>
&gt; Starting Value
</Label>
<Input
id="starting-value"
type="number"
min="0"
step="1"
autoFocus
value={startingValue}
onChange={(e) => setStartingValue(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
/>
<InputError message={error} />
<Button
type="submit"
disabled={processing || startingValue === ''}
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
[INITIALIZE]
</Button>
</form>
</div>
</div>
</div>
);
}

View file

@ -1,127 +0,0 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { useForm } from '@inertiajs/react';
import { todayISO } from '@/lib/utils';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useEffect, useState } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface EntryFormData {
date: string;
quantity: string;
[key: string]: string;
}
interface AddEntryFormProps {
unit?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
interface EntrySummary {
total_quantity: number;
}
export default function AddEntryForm({ unit = 'units', onSuccess, onCancel }: AddEntryFormProps) {
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
date: todayISO(),
quantity: '',
});
const [currentHoldings, setCurrentHoldings] = useState<EntrySummary | null>(null);
useEffect(() => {
const fetchSummary = async () => {
try {
const response = await fetch('/entries/summary');
if (response.ok) {
const summary = await response.json();
setCurrentHoldings(summary);
}
} catch (error) {
console.error('Failed to fetch entry summary:', error);
}
};
fetchSummary();
}, []);
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('entries.store'), {
onSuccess: () => {
reset();
setData('date', todayISO());
if (onSuccess) onSuccess();
},
});
};
return (
<div className="w-full">
<div className="space-y-4">
<ComponentTitle>ADD ENTRY</ComponentTitle>
{currentHoldings && currentHoldings.total_quantity > 0 && (
<p className="text-sm text-red-400/60 font-mono">
[CURRENT] {currentHoldings.total_quantity.toFixed(6)} {unit}
</p>
)}
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; Date</Label>
<Input
id="date"
type="date"
value={data.date}
onChange={(e) => setData('date', e.target.value)}
max={todayISO()}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red"
/>
<InputError message={errors.date} />
</div>
<div>
<Label htmlFor="quantity" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Quantity ({unit})
</Label>
<Input
id="quantity"
type="number"
step="0.000001"
min="0"
placeholder="1.234567"
value={data.quantity}
onChange={(e) => setData('quantity', e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
/>
<InputError message={errors.quantity} />
</div>
<div className="flex gap-3 pt-2">
<Button
type="submit"
disabled={processing}
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
[EXECUTE]
</Button>
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
[ABORT]
</Button>
)}
</div>
</form>
</div>
</div>
);
}

View file

@ -1,15 +0,0 @@
import { FC, ReactNode } from 'react';
interface ComponentTitleProps {
children: ReactNode;
}
const ComponentTitle: FC<ComponentTitleProps> = ({ children }) => {
return (
<h2 className="text-red-500 text-lg font-mono font-bold tracking-wider uppercase">
{ children }
</h2>
)
}
export default ComponentTitle

View file

@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
interface TerminalSpinnerProps {
text?: string;
size?: 'sm' | 'md' | 'lg';
fullScreen?: boolean;
}
export default function TerminalSpinner({
text = 'LOADING',
size = 'md',
fullScreen = false
}: TerminalSpinnerProps) {
const [dots, setDots] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDots(prev => (prev + 1) % 4);
}, 500);
return () => clearInterval(interval);
}, []);
const getDots = () => {
switch (dots) {
case 0: return ' ';
case 1: return '. ';
case 2: return '.. ';
case 3: return '...';
default: return ' ';
}
};
const sizeClasses = {
sm: 'text-sm',
md: 'text-lg',
lg: 'text-xl'
};
const spinner = (
<div className="border border-red-500/30 bg-black p-6">
<span className={`text-red-500 font-mono ${sizeClasses[size]} uppercase tracking-wider`}>
[SYSTEM] {text}<span className="inline-block w-8">{getDots()}</span>
</span>
</div>
);
if (fullScreen) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
{spinner}
</div>
);
}
return spinner;
}

View file

@ -1,58 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -1,22 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -1,73 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
export type Appearance = 'light' | 'dark' | 'system';
const prefersDark = () => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
}
const maxAge = days * 24 * 60 * 60;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};
const applyTheme = (appearance: Appearance) => {
const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark());
document.documentElement.classList.toggle('dark', isDark);
};
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
const handleSystemThemeChange = () => {
const currentAppearance = localStorage.getItem('appearance') as Appearance;
applyTheme(currentAppearance || 'system');
};
export function initializeTheme() {
const savedAppearance = (localStorage.getItem('appearance') as Appearance) || 'system';
applyTheme(savedAppearance);
// Add the event listener for system theme changes...
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
}
export function useAppearance() {
const [appearance, setAppearance] = useState<Appearance>('system');
const updateAppearance = useCallback((mode: Appearance) => {
setAppearance(mode);
// Store in localStorage for client-side persistence...
localStorage.setItem('appearance', mode);
// Store in cookie for SSR...
setCookie('appearance', mode);
applyTheme(mode);
}, []);
useEffect(() => {
const savedAppearance = localStorage.getItem('appearance') as Appearance | null;
updateAppearance(savedAppearance || 'system');
return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange);
}, [updateAppearance]);
return { appearance, updateAppearance } as const;
}

View file

@ -1,8 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const todayISO = (): string => new Date().toISOString().split('T')[0];

View file

@ -1,174 +0,0 @@
import LedDisplay from '@/components/Display/LedDisplay';
import InlineForm from '@/components/Display/InlineForm';
import ProgressBar from '@/components/Display/ProgressBar';
import StatsBox from '@/components/Display/StatsBox';
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
import TerminalSpinner from '@/components/ui/TerminalSpinner';
import { Head } from '@inertiajs/react';
import { useCallback, useEffect, useState } from 'react';
import type { Milestone, Tracker } from '@/types/domain';
export default function Dashboard() {
const [totalShares, setTotalShares] = useState(0);
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0);
const [showProgressBar, setShowProgressBar] = useState(false);
const [showStatsBox, setShowStatsBox] = useState(false);
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
const [loading, setLoading] = useState(true);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [tracker, setTracker] = useState<Tracker | null>(null);
const loadData = useCallback(async () => {
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/milestones'),
fetch('/tracker'),
]);
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
}
if (milestonesResponse.ok) {
setMilestones(await milestonesResponse.json());
}
let trackerExists = false;
if (trackerResponse.ok) {
const { exists, tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
trackerExists = Boolean(exists);
}
setNeedsOnboarding(!trackerExists);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
await loadData();
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [loadData]);
const handlePurchaseSuccess = async () => {
try {
const entriesResponse = await fetch('/entries/summary');
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
}
} catch (error) {
console.error('Failed to refresh entry data:', error);
}
};
const handleMilestoneSuccess = async () => {
try {
const milestonesResponse = await fetch('/milestones');
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
setSelectedMilestoneIndex(0);
}
} catch (error) {
console.error('Failed to refresh milestone data:', error);
}
};
const handleMilestoneSelect = (index: number) => {
setSelectedMilestoneIndex(index);
};
const handleOnboardingComplete = loadData;
if (loading) {
return (
<>
<Head title="Dashboard" />
<TerminalSpinner fullScreen />
</>
);
}
const handleLedClick = () => {
const newShowProgressBar = !showProgressBar;
setShowProgressBar(newShowProgressBar);
if (!newShowProgressBar) {
setShowStatsBox(false);
}
};
const handleProgressClick = () => {
setShowStatsBox(!showStatsBox);
setActiveForm(null);
};
if (needsOnboarding) {
return (
<>
<Head title="Asset Tracker - Setup" />
<OnboardingFlow onComplete={handleOnboardingComplete} />
</>
);
}
return (
<>
<Head title="incr" />
<div className="min-h-screen bg-black">
<div className="w-full max-w-4xl mx-auto px-4">
<div className="pt-32">
<LedDisplay
value={totalShares}
onClick={handleLedClick}
/>
</div>
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
<ProgressBar
currentQuantity={totalShares}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onClick={handleProgressClick}
/>
</div>
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
<StatsBox
stats={{ totalShares }}
unit={tracker?.unit}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onMilestoneSelect={handleMilestoneSelect}
onAddPurchase={() => setActiveForm('purchase')}
onAddMilestone={() => setActiveForm('milestone')}
/>
</div>
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
<InlineForm
type={activeForm}
unit={tracker?.unit}
onClose={() => setActiveForm(null)}
onSuccess={(type) => {
if (type === 'purchase') handlePurchaseSuccess();
else if (type === 'milestone') handleMilestoneSuccess();
}}
/>
</div>
</div>
</div>
</>
);
}

View file

@ -1,30 +0,0 @@
import { createInertiaApp } from '@inertiajs/react';
import createServer from '@inertiajs/react/server';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import ReactDOMServer from 'react-dom/server';
import { type RouteName, route } from 'ziggy-js';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createServer((page) =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
title: (title) => title ? `${title} - ${appName}` : appName,
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
setup: ({ App, props }) => {
/* eslint-disable */
// @ts-expect-error
global.route<RouteName> = (name, params, absolute) =>
route(name, params as any, absolute, {
// @ts-expect-error
...page.props.ziggy,
// @ts-expect-error
location: new URL(page.props.ziggy.location),
});
/* eslint-enable */
return <App {...props} />;
},
}),
);

View file

@ -1,20 +0,0 @@
export interface Milestone {
id?: number;
target: number;
description: string;
created_at: string;
}
export interface TrackerAsset {
id: number;
symbol: string;
full_name: string | null;
}
export interface Tracker {
id: number;
label: string;
unit: string;
price_tracking_enabled: boolean;
asset: TrackerAsset | null;
}

View file

@ -1,5 +0,0 @@
import type { route as routeFn } from 'ziggy-js';
declare global {
const route: typeof routeFn;
}

View file

@ -1,43 +0,0 @@
import { LucideIcon } from 'lucide-react';
import type { Config } from 'ziggy-js';
export interface Auth {
user: User;
}
export interface BreadcrumbItem {
title: string;
href: string;
}
export interface NavGroup {
title: string;
items: NavItem[];
}
export interface NavItem {
title: string;
href: string;
icon?: LucideIcon | null;
isActive?: boolean;
}
export interface SharedData {
name: string;
quote: { message: string; author: string };
auth: Auth;
ziggy: Config & { location: string };
sidebarOpen: boolean;
[key: string]: unknown;
}
export interface User {
id: number;
name: string;
email: string;
avatar?: string;
email_verified_at: string | null;
created_at: string;
updated_at: string;
[key: string]: unknown; // This allows for additional properties...
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,53 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
(function() {
const appearance = '{{ $appearance ?? "system" }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.classList.add('dark');
}
}
})();
</script>
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style>
html {
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style>
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|major-mono-display:400" rel="stylesheet" />
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
@routes
@viteReactRefresh
@vite(['resources/js/app.tsx'])
@inertiaHead
</head>
<body class="font-sans antialiased">
@inertia
</body>
</html>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'incr') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
@vite(['resources/css/app.css'])
@livewireStyles
</head>
<body class="bg-black antialiased">
{{ $slot }}
@livewireScripts
</body>
</html>

View file

@ -0,0 +1,100 @@
<div class="min-h-screen bg-black">
@if ($needsOnboarding)
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
<form wire:submit="initialise" class="space-y-4">
<label for="starting-value" class="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Starting Value
</label>
<input
id="starting-value"
type="number"
min="0"
step="1"
autofocus
wire:model="value"
class="w-full bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red px-3 py-2"
>
@error('value')
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
@enderror
<button
type="submit"
class="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
>
[INITIALIZE]
</button>
</form>
</div>
</div>
</div>
@else
<div class="w-full max-w-4xl mx-auto px-4">
<div class="pt-32">
<div
wire:click="increment"
class="w-full text-center select-none cursor-pointer bg-black text-red-500 px-8 py-12 transition-all duration-300"
>
<div class="relative w-full flex items-center justify-center">
<div
class="relative z-10 text-[8rem] md:text-[12rem] lg:text-[16rem] font-digital font-normal text-red-500 drop-shadow-[0_0_10px_rgba(239,68,68,0.8)] filter brightness-110 leading-none transition-all duration-300"
style="letter-spacing: 0.15em"
>{{ $count }}</div>
</div>
</div>
</div>
<div class="text-center">
<button
type="button"
wire:click="edit"
class="text-red-400/60 hover:text-red-400 font-mono text-xs uppercase tracking-widest transition-colors"
>
[SET VALUE]
</button>
</div>
@if ($editing)
<div class="bg-black p-8">
<div class="w-full border-4 border-red-500 p-6 bg-black glow-red">
<form wire:submit="save" class="space-y-4">
<label for="count" class="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Set Value
</label>
<input
id="count"
type="number"
min="0"
step="1"
autofocus
wire:model="value"
class="w-full bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red px-3 py-2"
>
@error('value')
<p class="text-red-400 font-mono text-xs">{{ $message }}</p>
@enderror
<div class="flex gap-3 pt-2">
<button
type="submit"
class="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
>
[EXECUTE]
</button>
<button
type="button"
wire:click="cancel"
class="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red px-4 py-2"
>
[ABORT]
</button>
</div>
</form>
</div>
</div>
@endif
</div>
@endif
</div>

View file

@ -1,8 +0,0 @@
<?php
use App\Http\Controllers\Auth\RegisteredUserController;
use Illuminate\Support\Facades\Route;
// First-run setup only — gated by User::exists() in the controller
Route::get('register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);

View file

@ -1,54 +1,6 @@
<?php
use App\Http\Controllers\AssetController;
use App\Http\Controllers\Milestones\MilestoneController;
use App\Http\Controllers\Pricing\PricingController;
use App\Http\Controllers\TrackerController;
use App\Http\Controllers\Transactions\EntryController;
use App\Livewire\Counter;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return redirect('/dashboard');
})->name('home');
Route::get('dashboard', function () {
return Inertia::render('dashboard');
})->name('dashboard');
// Tracker routes
Route::get('/tracker', [TrackerController::class, 'show'])->name('tracker.show');
Route::post('/tracker', [TrackerController::class, 'store'])->name('tracker.store');
Route::patch('/tracker', [TrackerController::class, 'update'])->name('tracker.update');
// Asset routes
Route::prefix('assets')->name('assets.')->group(function () {
Route::get('/', [AssetController::class, 'index'])->name('index');
Route::post('/', [AssetController::class, 'store'])->name('store');
Route::get('/search', [AssetController::class, 'search'])->name('search');
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
});
// Entry routes (replaces purchases)
Route::prefix('entries')->name('entries.')->group(function () {
Route::get('/', [EntryController::class, 'index'])->name('index');
Route::post('/', [EntryController::class, 'store'])->name('store');
Route::get('/summary', [EntryController::class, 'summary'])->name('summary');
Route::delete('/{entry}', [EntryController::class, 'destroy'])->name('destroy');
});
// Pricing routes
Route::prefix('pricing')->name('pricing.')->group(function () {
Route::get('/current', [PricingController::class, 'current'])->name('current');
Route::post('/update', [PricingController::class, 'update'])->name('update');
Route::get('/history', [PricingController::class, 'history'])->name('history');
Route::get('/date/{date}', [PricingController::class, 'forDate'])->name('for-date');
});
// Milestone routes
Route::prefix('milestones')->name('milestones.')->group(function () {
Route::get('/', [MilestoneController::class, 'index'])->name('index');
Route::post('/', [MilestoneController::class, 'store'])->name('store');
});
require __DIR__.'/auth.php';
Route::get('/', Counter::class)->name('home');

0
tests/Feature/.gitkeep Normal file
View file

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CountBackfillMigrationTest extends TestCase
{
use RefreshDatabase;
private const MIGRATION = __DIR__.'/../../database/migrations/2026_08_15_000002_add_count_to_trackers_drop_entries.php';
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): Migration
{
return require self::MIGRATION;
}
/**
* Rebuild the pre-migration shape: entries present, trackers without a count.
*/
private function revertToLedger(): void
{
$this->migration()->down();
}
private function makeTracker(): int
{
return DB::table('trackers')->insertGetId([
'label' => 'Counter',
'unit' => 'units',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function addEntry(int $trackerId, string $quantity): void
{
DB::table('entries')->insert([
'tracker_id' => $trackerId,
'date' => now()->toDateString(),
'quantity' => $quantity,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function countFor(int $trackerId): int
{
return (int) DB::table('trackers')->where('id', $trackerId)->value('count');
}
public function test_backfill_sums_entries_into_count(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->addEntry($trackerId, '10');
$this->addEntry($trackerId, '15');
$this->migration()->up();
$this->assertSame(25, $this->countFor($trackerId));
}
public function test_backfill_rounds_fractional_totals(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->addEntry($trackerId, '1.4');
$this->addEntry($trackerId, '1.2');
$this->migration()->up();
$this->assertSame(3, $this->countFor($trackerId));
}
public function test_backfill_clamps_negative_totals_to_zero(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->addEntry($trackerId, '5');
$this->addEntry($trackerId, '-9');
$this->migration()->up();
$this->assertSame(0, $this->countFor($trackerId));
}
public function test_tracker_without_entries_defaults_to_zero(): void
{
$this->revertToLedger();
$trackerId = $this->makeTracker();
$this->migration()->up();
$this->assertSame(0, $this->countFor($trackerId));
}
public function test_up_drops_the_entries_table(): void
{
$this->revertToLedger();
$this->assertTrue(Schema::hasTable('entries'));
$this->migration()->up();
$this->assertFalse(Schema::hasTable('entries'));
$this->assertTrue(Schema::hasColumn('trackers', 'count'));
}
// The two down() tests start from the migrated schema, so they do not call revertToLedger().
public function test_down_recreates_entries_from_count(): void
{
$trackerId = $this->makeTracker();
DB::table('trackers')->where('id', $trackerId)->update(['count' => 7]);
$this->migration()->down();
$this->assertTrue(Schema::hasTable('entries'));
$this->assertFalse(Schema::hasColumn('trackers', 'count'));
$entries = DB::table('entries')->where('tracker_id', $trackerId)->get();
$this->assertCount(1, $entries);
$this->assertSame(7.0, (float) $entries->first()->quantity);
}
public function test_down_writes_no_entry_for_a_zero_count(): void
{
$trackerId = $this->makeTracker();
$this->migration()->down();
$this->assertSame(0, DB::table('entries')->where('tracker_id', $trackerId)->count());
}
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Livewire\Counter;
use App\Models\Tracker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class CounterTest extends TestCase
{
use RefreshDatabase;
private function tracker(int $count = 0): Tracker
{
return Tracker::factory()->create(['count' => $count]);
}
public function test_the_page_renders_the_current_count(): void
{
$this->tracker(42);
$this->get('/')
->assertOk()
->assertSee('42');
}
public function test_increment_adds_exactly_one(): void
{
$tracker = $this->tracker(5);
Livewire::test(Counter::class)
->call('increment')
->assertSet('count', 6);
$this->assertSame(6, $tracker->refresh()->count);
}
public function test_increments_accumulate(): void
{
$tracker = $this->tracker();
Livewire::test(Counter::class)
->call('increment')
->call('increment')
->call('increment')
->assertSet('count', 3);
$this->assertSame(3, $tracker->refresh()->count);
}
public function test_count_can_be_set_to_an_absolute_value(): void
{
$tracker = $this->tracker(3);
Livewire::test(Counter::class)
->call('edit')
->set('value', 250)
->call('save')
->assertSet('count', 250)
->assertSet('editing', false);
$this->assertSame(250, $tracker->refresh()->count);
}
public function test_count_can_be_set_to_zero(): void
{
$tracker = $this->tracker(42);
Livewire::test(Counter::class)
->call('edit')
->set('value', 0)
->call('save');
$this->assertSame(0, $tracker->refresh()->count);
}
/**
* @return array<string, array{mixed}>
*/
public static function invalidCounts(): array
{
return [
'negative' => [-1],
'above unsigned int ceiling' => [4294967296],
];
}
#[DataProvider('invalidCounts')]
public function test_save_rejects_invalid_values(mixed $value): void
{
$tracker = $this->tracker(7);
Livewire::test(Counter::class)
->call('edit')
->set('value', $value)
->call('save')
->assertHasErrors('value');
$this->assertSame(7, $tracker->refresh()->count);
}
public function test_cancel_discards_the_edit(): void
{
$tracker = $this->tracker(9);
Livewire::test(Counter::class)
->call('edit')
->set('value', 500)
->call('cancel')
->assertSet('count', 9)
->assertSet('editing', false);
$this->assertSame(9, $tracker->refresh()->count);
}
public function test_increment_stops_at_the_column_ceiling(): void
{
$tracker = $this->tracker(Counter::MAX_COUNT);
Livewire::test(Counter::class)
->call('increment')
->assertSet('count', Counter::MAX_COUNT);
$this->assertSame(Counter::MAX_COUNT, $tracker->refresh()->count);
}
public function test_count_is_cast_to_an_integer(): void
{
$this->tracker(12);
$this->assertSame(12, Tracker::first()->count);
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DropAssetsMigrationTest extends TestCase
{
use RefreshDatabase;
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): Migration
{
return require __DIR__.'/../../database/migrations/2026_08_15_000003_drop_assets_and_pricing.php';
}
public function test_the_migrated_schema_has_no_asset_or_price_columns(): void
{
$this->assertFalse(Schema::hasTable('assets'));
$this->assertFalse(Schema::hasTable('asset_prices'));
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_down_restores_the_tables_and_columns(): void
{
$this->migration()->down();
$this->assertTrue(Schema::hasTable('assets'));
$this->assertTrue(Schema::hasTable('asset_prices'));
$this->assertTrue(Schema::hasColumn('trackers', 'asset_id'));
$this->assertTrue(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_up_drops_them_again_respecting_foreign_keys(): void
{
$this->migration()->down();
$this->migration()->up();
$this->assertFalse(Schema::hasTable('assets'));
$this->assertFalse(Schema::hasTable('asset_prices'));
$this->assertFalse(Schema::hasColumn('trackers', 'asset_id'));
$this->assertFalse(Schema::hasColumn('trackers', 'price_tracking_enabled'));
}
public function test_dropping_assets_does_not_disturb_the_counter(): void
{
$this->migration()->down();
$trackerId = DB::table('trackers')->insertGetId([
'label' => 'Counter',
'unit' => 'units',
'count' => 31,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->up();
$this->assertSame(31, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DropUsersMigrationTest extends TestCase
{
use RefreshDatabase;
// Each require returns a fresh anonymous-class instance; there is no name to collide.
private function migration(): Migration
{
return require __DIR__.'/../../database/migrations/2026_08_15_000004_drop_users_and_sessions.php';
}
public function test_the_migrated_schema_has_no_user_tables(): void
{
$this->assertFalse(Schema::hasTable('users'));
$this->assertFalse(Schema::hasTable('sessions'));
$this->assertFalse(Schema::hasTable('password_reset_tokens'));
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
}
public function test_down_restores_the_tables_and_column(): void
{
$this->migration()->down();
$this->assertTrue(Schema::hasTable('users'));
$this->assertTrue(Schema::hasTable('sessions'));
$this->assertTrue(Schema::hasTable('password_reset_tokens'));
$this->assertTrue(Schema::hasColumn('trackers', 'user_id'));
}
public function test_restored_user_id_is_nullable_so_existing_trackers_survive(): void
{
DB::table('trackers')->insert([
'label' => 'Counter',
'unit' => 'units',
'count' => 3,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->down();
$nullable = DB::selectOne(
'select is_nullable from information_schema.columns
where table_schema = database() and table_name = ? and column_name = ?',
['trackers', 'user_id']
);
$this->assertSame('YES', $nullable->is_nullable ?? $nullable->IS_NULLABLE);
}
public function test_up_drops_them_again_respecting_foreign_keys(): void
{
$this->migration()->down();
$this->migration()->up();
$this->assertFalse(Schema::hasTable('users'));
$this->assertFalse(Schema::hasColumn('trackers', 'user_id'));
}
public function test_the_counter_survives_the_round_trip(): void
{
$trackerId = DB::table('trackers')->insertGetId([
'label' => 'Counter',
'unit' => 'units',
'count' => 77,
'created_at' => now(),
'updated_at' => now(),
]);
$this->migration()->down();
$this->migration()->up();
$this->assertSame(77, (int) DB::table('trackers')->where('id', $trackerId)->value('count'));
}
}

View file

@ -1,73 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\Milestone;
use App\Models\Tracker;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MilestoneTest extends TestCase
{
use RefreshDatabase;
private function tracker(): Tracker
{
return Tracker::create([
'user_id' => User::default()->id,
'label' => 'Test',
'unit' => 'units',
]);
}
public function test_can_create_milestone(): void
{
$tracker = $this->tracker();
$milestone = Milestone::create([
'tracker_id' => $tracker->id,
'target' => 1500,
'description' => 'First milestone',
]);
$this->assertDatabaseHas('milestones', [
'target' => 1500,
'description' => 'First milestone',
]);
$this->assertEquals(1500, $milestone->target);
$this->assertEquals('First milestone', $milestone->description);
}
public function test_can_fetch_milestones_via_api(): void
{
$tracker = $this->tracker();
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1500, 'description' => 'First milestone']);
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Second milestone']);
$response = $this->get('/milestones');
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJson([
['target' => 1500, 'description' => 'First milestone'],
['target' => 3000, 'description' => 'Second milestone'],
]);
}
public function test_milestones_ordered_by_target(): void
{
$tracker = $this->tracker();
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Third']);
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1000, 'description' => 'First']);
Milestone::create(['tracker_id' => $tracker->id, 'target' => 2000, 'description' => 'Second']);
$response = $this->get('/milestones');
$milestones = $response->json();
$this->assertEquals(1000, $milestones[0]['target']);
$this->assertEquals(2000, $milestones[1]['target']);
$this->assertEquals(3000, $milestones[2]['target']);
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Livewire\Counter;
use App\Models\Tracker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TrackerTest extends TestCase
{
use RefreshDatabase;
public function test_a_fresh_install_asks_for_a_starting_value(): void
{
Livewire::test(Counter::class)
->assertSet('needsOnboarding', true)
->assertSee('Starting Value');
}
public function test_initialise_creates_the_counter(): void
{
Livewire::test(Counter::class)
->set('value', 47)
->call('initialise')
->assertSet('needsOnboarding', false)
->assertSet('count', 47);
$tracker = Tracker::first();
$this->assertNotNull($tracker);
$this->assertSame(47, $tracker->count);
$this->assertSame('Counter', $tracker->label);
}
public function test_a_counter_can_start_at_zero(): void
{
Livewire::test(Counter::class)
->set('value', 0)
->call('initialise')
->assertSet('needsOnboarding', false)
->assertSet('count', 0);
$this->assertSame(0, Tracker::first()->count);
}
public function test_a_zero_count_does_not_reopen_onboarding(): void
{
Tracker::factory()->create(['count' => 0]);
Livewire::test(Counter::class)
->assertSet('needsOnboarding', false);
}
public function test_initialise_rejects_a_negative_starting_value(): void
{
Livewire::test(Counter::class)
->set('value', -5)
->call('initialise')
->assertHasErrors('value')
->assertSet('needsOnboarding', true);
$this->assertSame(0, Tracker::count());
}
public function test_an_existing_counter_skips_onboarding(): void
{
Tracker::factory()->create(['count' => 9]);
Livewire::test(Counter::class)
->assertSet('needsOnboarding', false)
->assertSet('count', 9);
}
public function test_initialising_twice_does_not_create_a_second_counter(): void
{
$component = Livewire::test(Counter::class)->set('value', 5);
Tracker::factory()->create(['count' => 12]);
$component->call('initialise')->assertSet('count', 12);
$this->assertSame(1, Tracker::count());
}
}

0
tests/Unit/.gitkeep Normal file
View file

View file

@ -1,16 +0,0 @@
<?php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_that_true_is_true()
{
$this->assertTrue(true);
}
}

View file

@ -1,122 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ESNext" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */,
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
"noEmit": true /* Disable emitting files from a compilation. */,
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
"baseUrl": ".",
"paths": {
"@/*": ["./resources/js/*"],
"ziggy-js": ["./vendor/tightenco/ziggy"]
},
"jsx": "react-jsx"
},
"include": [
"resources/js/**/*.ts",
"resources/js/**/*.d.ts",
"resources/js/**/*.tsx",
]
}

View file

@ -1,37 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import laravel from 'laravel-vite-plugin';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css'],
refresh: true,
}),
tailwindcss(),
],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
cors: true,
hmr: {
host: 'localhost',
clientPort: 5173,
},
watch: {
usePolling: true,
ignored: ['**/storage/framework/views/**'],
},
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'],
ssr: 'resources/js/ssr.tsx',
refresh: true,
}),
react(),
tailwindcss(),
],
esbuild: {
jsx: 'automatic',
},
resolve: {
alias: {
'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'),
},
},
});