diff --git a/app/Http/Controllers/CounterController.php b/app/Http/Controllers/CounterController.php new file mode 100644 index 0000000..8a9c8a1 --- /dev/null +++ b/app/Http/Controllers/CounterController.php @@ -0,0 +1,42 @@ +tracker; + + if (! $tracker) { + return response()->json(['error' => 'No counter found.'], 404); + } + + $tracker->increment('count'); + + return response()->json(['count' => $tracker->refresh()->count]); + } + + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'count' => 'required|integer|min:0|max:4294967295', + ]); + + $tracker = User::default()->tracker; + + if (! $tracker) { + return response()->json(['error' => 'No counter found.'], 404); + } + + $tracker->update(['count' => $validated['count']]); + + return response()->json(['count' => $tracker->count]); + } +} diff --git a/app/Http/Controllers/Transactions/EntryController.php b/app/Http/Controllers/Transactions/EntryController.php deleted file mode 100644 index ccc8031..0000000 --- a/app/Http/Controllers/Transactions/EntryController.php +++ /dev/null @@ -1,91 +0,0 @@ -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!', - ]); - } -} diff --git a/app/Models/Tracker.php b/app/Models/Tracker.php index fb3edf5..d37ed45 100644 --- a/app/Models/Tracker.php +++ b/app/Models/Tracker.php @@ -4,10 +4,8 @@ namespace App\Models; -use App\Models\Transactions\Entry; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasMany; class Tracker extends Model { @@ -16,12 +14,14 @@ class Tracker extends Model 'asset_id', 'label', 'unit', + 'count', 'price_tracking_enabled', ]; protected function casts(): array { return [ + 'count' => 'integer', 'price_tracking_enabled' => 'boolean', ]; } @@ -35,9 +35,4 @@ public function asset(): BelongsTo { return $this->belongsTo(Asset::class); } - - public function entries(): HasMany - { - return $this->hasMany(Entry::class); - } } diff --git a/app/Models/Transactions/Entry.php b/app/Models/Transactions/Entry.php deleted file mode 100644 index f5ca706..0000000 --- a/app/Models/Transactions/Entry.php +++ /dev/null @@ -1,53 +0,0 @@ - '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; - } -} diff --git a/app/Models/User.php b/app/Models/User.php index f08a6ed..eeef95a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -46,9 +46,4 @@ public static function default(): self 'password' => bcrypt(Str::random(32)), ]); } - - public function hasEntries(): bool - { - return (bool) $this->tracker?->entries()->exists(); - } } diff --git a/database/migrations/2026_08_15_000002_add_count_to_trackers_drop_entries.php b/database/migrations/2026_08_15_000002_add_count_to_trackers_drop_entries.php new file mode 100644 index 0000000..f7940ab --- /dev/null +++ b/database/migrations/2026_08_15_000002_add_count_to_trackers_drop_entries.php @@ -0,0 +1,60 @@ +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'); + }); + } +}; diff --git a/resources/js/components/Counter/SetCountForm.tsx b/resources/js/components/Counter/SetCountForm.tsx new file mode 100644 index 0000000..9e06d78 --- /dev/null +++ b/resources/js/components/Counter/SetCountForm.tsx @@ -0,0 +1,95 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/InputError'; +import { csrfToken } from '@/lib/utils'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler, useState } from 'react'; + +interface SetCountFormProps { + currentCount: number; + onClose: () => void; + onSuccess: (count: number) => void; +} + +export default function SetCountForm({ currentCount, onClose, onSuccess }: SetCountFormProps) { + const [value, setValue] = useState(String(currentCount)); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(); + + const submit: FormEventHandler = async (e) => { + e.preventDefault(); + setProcessing(true); + setError(undefined); + + try { + const response = await fetch('/count', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + Accept: 'application/json', + }, + body: JSON.stringify({ count: Number(value) }), + }); + + if (!response.ok) { + setError('Enter a whole number of zero or more.'); + return; + } + + const { count } = await response.json(); + onSuccess(count); + onClose(); + } catch { + setError('Something went wrong. Please try again.'); + } finally { + setProcessing(false); + } + }; + + return ( +
+
+
+ + setValue(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" + /> + + +
+ + +
+ +
+
+ ); +} diff --git a/resources/js/components/Display/InlineForm.tsx b/resources/js/components/Display/InlineForm.tsx deleted file mode 100644 index 5069e11..0000000 --- a/resources/js/components/Display/InlineForm.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import AddEntryForm from '@/components/Transactions/AddEntryForm'; -import { cn } from '@/lib/utils'; - -interface InlineFormProps { - open: boolean; - unit?: string; - onClose: () => void; - onSuccess?: () => void; - className?: string; -} - -export default function InlineForm({ - open, - unit = 'units', - onClose, - onSuccess, - className, -}: InlineFormProps) { - if (!open) return null; - - const handleSuccess = () => { - onSuccess?.(); - onClose(); - }; - - return ( -
-
-
- -
-
-
- ); -} diff --git a/resources/js/components/Onboarding/OnboardingFlow.tsx b/resources/js/components/Onboarding/OnboardingFlow.tsx index a379c7f..42a524e 100644 --- a/resources/js/components/Onboarding/OnboardingFlow.tsx +++ b/resources/js/components/Onboarding/OnboardingFlow.tsx @@ -2,7 +2,7 @@ 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 { csrfToken } from '@/lib/utils'; import { LoaderCircle } from 'lucide-react'; import { FormEventHandler, useState } from 'react'; @@ -10,10 +10,6 @@ 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); @@ -42,16 +38,16 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { return; } - const quantity = Number(startingValue); + const count = Number(startingValue); - if (quantity > 0) { - const entryResponse = await fetch(route('entries.store'), { - method: 'POST', + if (count > 0) { + const countResponse = await fetch('/count', { + method: 'PATCH', headers, - body: JSON.stringify({ date: todayISO(), quantity }), + body: JSON.stringify({ count }), }); - if (!entryResponse.ok) { + if (!countResponse.ok) { setError('Could not save the starting value. Please try again.'); return; } diff --git a/resources/js/components/Transactions/AddEntryForm.tsx b/resources/js/components/Transactions/AddEntryForm.tsx deleted file mode 100644 index ec75cbc..0000000 --- a/resources/js/components/Transactions/AddEntryForm.tsx +++ /dev/null @@ -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({ - date: todayISO(), - quantity: '', - }); - - const [currentHoldings, setCurrentHoldings] = useState(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 ( -
-
- ADD ENTRY - {currentHoldings && currentHoldings.total_quantity > 0 && ( -

- [CURRENT] {currentHoldings.total_quantity.toFixed(6)} {unit} -

- )} -
-
- - 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" - /> - -
- -
- - 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" - /> - -
- -
- - {onCancel && ( - - )} -
-
-
-
- ); -} diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts index f04a0b2..1f83a7c 100644 --- a/resources/js/lib/utils.ts +++ b/resources/js/lib/utils.ts @@ -5,4 +5,5 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export const todayISO = (): string => new Date().toISOString().split('T')[0]; +export const csrfToken = (): string => + (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? ''; diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index ca73103..348952f 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,38 +1,30 @@ import LedDisplay from '@/components/Display/LedDisplay'; -import InlineForm from '@/components/Display/InlineForm'; +import SetCountForm from '@/components/Counter/SetCountForm'; import OnboardingFlow from '@/components/Onboarding/OnboardingFlow'; import TerminalSpinner from '@/components/ui/TerminalSpinner'; +import { csrfToken } from '@/lib/utils'; import { Head } from '@inertiajs/react'; import { useCallback, useEffect, useState } from 'react'; -import type { Tracker } from '@/types/domain'; export default function Dashboard() { - const [totalShares, setTotalShares] = useState(0); + const [count, setCount] = useState(0); const [formOpen, setFormOpen] = useState(false); const [loading, setLoading] = useState(true); + const [incrementing, setIncrementing] = useState(false); const [needsOnboarding, setNeedsOnboarding] = useState(false); - const [tracker, setTracker] = useState(null); const loadData = useCallback(async () => { - const [entriesResponse, trackerResponse] = await Promise.all([ - fetch('/entries/summary'), - fetch('/tracker'), - ]); + const response = await fetch('/tracker'); - if (entriesResponse.ok) { - const entries = await entriesResponse.json(); - setTotalShares(entries.total_quantity); + if (!response.ok) { + setNeedsOnboarding(true); + return; } - let trackerExists = false; + const { exists, tracker } = await response.json(); - if (trackerResponse.ok) { - const { exists, tracker: trackerData } = await trackerResponse.json(); - setTracker(trackerData ?? null); - trackerExists = Boolean(exists); - } - - setNeedsOnboarding(!trackerExists); + setCount(tracker?.count ?? 0); + setNeedsOnboarding(!exists); }, []); useEffect(() => { @@ -49,20 +41,36 @@ export default function Dashboard() { fetchData(); }, [loadData]); - const handleEntrySuccess = async () => { + const increment = async () => { + if (incrementing) return; + + const previous = count; + setIncrementing(true); + setCount(previous + 1); + try { - const entriesResponse = await fetch('/entries/summary'); - if (entriesResponse.ok) { - const entries = await entriesResponse.json(); - setTotalShares(entries.total_quantity); + const response = await fetch('/increment', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': csrfToken(), + Accept: 'application/json', + }, + }); + + if (!response.ok) { + setCount(previous); + return; } - } catch (error) { - console.error('Failed to refresh entry data:', error); + + const { count: updated } = await response.json(); + setCount(updated); + } catch { + setCount(previous); + } finally { + setIncrementing(false); } }; - const handleOnboardingComplete = loadData; - if (loading) { return ( <> @@ -76,7 +84,7 @@ export default function Dashboard() { return ( <> - + ); } @@ -88,18 +96,26 @@ export default function Dashboard() {
- setFormOpen((open) => !open)} - /> +
- setFormOpen(false)} - onSuccess={handleEntrySuccess} - /> +
+ +
+ + {formOpen && ( + setFormOpen(false)} + onSuccess={setCount} + /> + )}
diff --git a/routes/web.php b/routes/web.php index ae89b5d..7ff9575 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,9 +1,9 @@ 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'); -}); +// Counter routes +Route::post('/increment', [CounterController::class, 'increment'])->name('counter.increment'); +Route::patch('/count', [CounterController::class, 'update'])->name('counter.update'); // Pricing routes Route::prefix('pricing')->name('pricing.')->group(function () {