From cfd1481797f44fbc23d7cbdeb09a49f9697785ed Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 09:29:16 +0200 Subject: [PATCH] Ein Streifen im Dashboard sagt, wie lange der Zugang noch steht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Mail sieben Tage vorher kann im Postfach untergehen; die eigene Uebersicht oeffnet der Kunde ohnehin. Der Streifen nennt das Datum, die Restzeit und was danach geschieht — dass der Zugang zur Cloud endet, nicht dass irgendetwas geloescht wird. Das ist die Frist, die ihn betrifft. Und er traegt die Antwort zum Export: wer liest, dass die Zeit laeuft, will es sich im selben Atemzug anders ueberlegen koennen (Aufgabe 4) — bis zum Laufzeitende, danach nicht mehr, geprueft direkt an der Methode und nicht nur am Schalter. Co-Authored-By: Claude Opus 5 --- app/Livewire/Dashboard.php | 76 +++++++++ lang/de/dashboard.php | 15 ++ lang/en/dashboard.php | 15 ++ resources/views/livewire/dashboard.blade.php | 39 +++++ .../Cancellation/DashboardCountdownTest.php | 158 ++++++++++++++++++ 5 files changed, 303 insertions(+) create mode 100644 tests/Feature/Cancellation/DashboardCountdownTest.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index f86b67c..ec8148c 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -79,10 +79,86 @@ class Dashboard extends Component // THIS customer above) rather than a fresh unscoped lookup, and // never stored as a public property — see acknowledgeCredentials(). 'credentials' => $this->credentials($instance, $domain), + // Die Frist, die den Kunden betrifft — wie lange SEIN Zugang noch + // steht, nicht wann irgendetwas geloescht wird. Siehe ending(). + 'ending' => $this->ending($instance), 'asOf' => Carbon::now(), ]); } + /** + * Aendert die Antwort zum Export, solange sie noch etwas bedeutet: bis + * zum Laufzeitende. Wie acknowledgeCredentials() loest diese Methode + * Kunde UND Instanz selbst neu auf, statt einer vom Browser hydrierten + * Eigenschaft zu vertrauen — sie ist ueber /livewire/update erreichbar, + * nicht nur ueber den Schalter im Streifen, und traegt deshalb dieselbe + * Bedingung wie ending() unten: verschwindet der Streifen, weil die + * Kuendigung nicht mehr aktiv ist oder das Laufzeitende vorbei ist, darf + * dieser Weg auch am Formular vorbei nichts mehr aendern. + */ + public function setExportWish(bool $wish): void + { + $customer = $this->requireCustomer(); + if ($customer === null) { + return; + } + + $instance = $customer->instances() + ->where('status', 'cancellation_scheduled') + ->where('service_ends_at', '>', now()) + ->latest('id') + ->first(); + + if ($instance === null) { + return; + } + + $instance->update(['export_wish' => $wish]); + } + + /** + * Was der Streifen ueber die Restfrist braucht — oder null, wenn er gar + * nicht erscheinen soll. + * + * Zeigt sich nur, wenn eine Kuendigung vorgemerkt ist UND das + * Laufzeitende noch in der Zukunft liegt. Der zweite Teil ist kein + * Sonderfall am Rand: der stuendliche Lauf, der eine abgelaufene Instanz + * auf `ended` stellt, laesst einen DNS-Fehler bewusst durch + * (EndInstanceService) und kann deshalb real tagelang hinterherhinken. + * Ohne diese Pruefung wuerde der Streifen in genau diesem Fenster eine + * abgelaufene Frist als Restzeit ausgeben — der Fehler, den niemand + * meldet, weil er wie eine Kleinigkeit aussieht, und den jeder sieht. + * + * @return array{ends_at: Carbon, days: int, hours: int, show_days: bool}|null + */ + private function ending(?Instance $instance): ?array + { + if ($instance === null || $instance->status !== 'cancellation_scheduled' || $instance->service_ends_at === null) { + return null; + } + + if (! $instance->service_ends_at->isFuture()) { + return null; + } + + // Nie negativ, auch nicht auf dem Papier: geklemmt, obwohl die + // Pruefung oben das im Normalfall schon ausschliesst — die Zeit + // zwischen der Berechnung hier und der Anzeige im Browser soll sich + // nie als "-1 Sekunde" durchschlagen koennen. + $seconds = max(0, $instance->service_ends_at->getTimestamp() - Carbon::now()->getTimestamp()); + + return [ + 'ends_at' => $instance->service_ends_at, + // Abgerundet auf ganze Tage, solange mehr als ein Tag bleibt — + // "noch 12 Tage" liest sich, "noch 11 Tage 7 Stunden" nicht. Am + // letzten Tag zaehlt sie Stunden; das ist der Moment, in dem die + // Zahl wirklich zaehlt. + 'days' => intdiv($seconds, 86400), + 'hours' => intdiv($seconds, 3600), + 'show_days' => $seconds > 86400, + ]; + } + /** * Deletes the instance's stored admin password and stamps the * acknowledgement — the customer has confirmed they noted it down. diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 3ad692c..9446cb7 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -37,6 +37,21 @@ return [ 'cancellation_scheduled' => 'Kündigung vorgemerkt', ], + // Der Streifen, der sagt, wie lange der Zugang noch steht — nicht, wann + // irgendetwas geloescht wird. Erscheint nur bei vorgemerkter Kuendigung + // mit Laufzeitende in der Zukunft (App\Livewire\Dashboard::ending()). + 'ending' => [ + 'title' => 'Ihr Zugang endet', + 'until' => 'Ab :date kommen Sie nicht mehr an Ihre Cloud.', + 'remaining_days' => '{1} Noch 1 Tag.|[2,*] Noch :count Tage.', + 'remaining_hours' => '{0} Weniger als eine Stunde.|{1} Noch 1 Stunde.|[2,*] Noch :count Stunden.', + 'what_then' => 'Danach ist der Zugang zu Ihrer Nextcloud geschlossen. Laden Sie sich Ihre Daten bis dahin selbst herunter.', + 'export_label' => 'Datenexport zum Laufzeitende', + 'export_hint' => 'Sie können sich das bis zum Laufzeitende jederzeit anders überlegen.', + 'export_yes' => 'Export gewünscht', + 'export_no' => 'Kein Export gewünscht', + ], + 'no_instance_label' => 'Noch keine Instanz', 'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.', 'no_instance_cta' => 'Paket wählen', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 7ba9f09..61f1082 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -37,6 +37,21 @@ return [ 'cancellation_scheduled' => 'Cancellation scheduled', ], + // The strip that says how long access still stands — not when anything + // gets deleted. Only appears once cancellation is scheduled and the end + // of the term is still in the future (App\Livewire\Dashboard::ending()). + 'ending' => [ + 'title' => 'Your access ends', + 'until' => 'From :date you will no longer be able to reach your cloud.', + 'remaining_days' => '{1} 1 day left.|[2,*] :count days left.', + 'remaining_hours' => '{0} Less than an hour left.|{1} 1 hour left.|[2,*] :count hours left.', + 'what_then' => 'After that your Nextcloud is closed off. Download your data yourself before then.', + 'export_label' => 'Data export at the end of the term', + 'export_hint' => 'You can change your mind about this at any time until the end of the term.', + 'export_yes' => 'Export wanted', + 'export_no' => 'No export wanted', + ], + 'no_instance_label' => 'No instance yet', 'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.', 'no_instance_cta' => 'Choose a package', diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index ddeb12b..14dc239 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -102,6 +102,45 @@ @endif + @if ($ending) + {{-- Die Frist, die den Kunden betrifft: wie lange SEIN Zugang noch + steht — nicht, wann irgendetwas geloescht wird. Dieselbe + Warnfarbe und Bauform wie der Zugangsdaten-Kasten darueber, an + derselben Stelle im Fluss: eine Sprache fuer Dringlichkeit auf + dieser Seite, nicht zwei. --}} +
+
+ +
+

{{ __('dashboard.ending.title') }}

+

+ {{ __('dashboard.ending.until', ['date' => $ending['ends_at']->local()->locale($locale)->isoFormat('LL')]) }} + {{ $ending['show_days'] + ? trans_choice('dashboard.ending.remaining_days', $ending['days']) + : trans_choice('dashboard.ending.remaining_hours', $ending['hours']) }} +

+

{{ __('dashboard.ending.what_then') }}

+ + {{-- Aufgabe 4: dieselbe Frage wie beim Kuendigen, hier mit + der Moeglichkeit, sie sich anders zu ueberlegen — bis + zum Laufzeitende (setExportWish() prueft das selbst + nach). --}} +
+ +
+
+
+
+ @endif + @if ($instance === null) {{-- Said plainly rather than papered over with an empty record: a page of dashes reads as broken, and "we are still setting it up" and diff --git a/tests/Feature/Cancellation/DashboardCountdownTest.php b/tests/Feature/Cancellation/DashboardCountdownTest.php new file mode 100644 index 0000000..5502476 --- /dev/null +++ b/tests/Feature/Cancellation/DashboardCountdownTest.php @@ -0,0 +1,158 @@ +create(['email' => 'gekuendigt@example.test', 'email_verified_at' => now()]); + + $customer = Customer::factory()->create([ + 'email' => 'gekuendigt@example.test', + 'user_id' => $user->id, + 'status' => 'active', + ]); + + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']); + + $instance = Instance::factory()->create([ + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'plan' => 'team', + 'status' => $status, + 'subdomain' => 'gekuendigt', + 'cancel_requested_at' => now()->subDays(2), + 'service_ends_at' => $endetIn, + ]); + + return [$user, $instance->refresh()]; +} + +/** + * Ein Kunde mit einer laufenden Instanz, die niemand gekündigt hat — + * dieselbe Form wie gekuendigteInstanz(), nur ohne Kündigung. + * + * @return array{0: User, 1: Instance} + */ +function laufendeInstanz(): array +{ + $user = User::factory()->create(['email' => 'laeuft@example.test', 'email_verified_at' => now()]); + + $customer = Customer::factory()->create([ + 'email' => 'laeuft@example.test', + 'user_id' => $user->id, + 'status' => 'active', + ]); + + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']); + + $instance = Instance::factory()->create([ + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'plan' => 'team', + 'status' => 'active', + 'subdomain' => 'laeuft', + ]); + + return [$user, $instance->refresh()]; +} + +it('zeigt nach einer Kuendigung, wie lange der Zugang noch steht', function () { + [$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12)); + + Livewire::actingAs($user)->test(Dashboard::class) + ->assertSee(__('dashboard.ending.title')) + ->assertSee($instance->service_ends_at->local()->isoFormat('LL')); +}); + +it('zeigt den Streifen nicht, solange nicht gekuendigt wurde', function () { + [$user] = laufendeInstanz(); + + Livewire::actingAs($user)->test(Dashboard::class) + ->assertDontSee(__('dashboard.ending.title')); +}); + +it('zeigt ihn auch nach dem Laufzeitende nicht mehr', function () { + // Danach ist der Zugang zu, und ein Streifen, der eine Restzeit nennt, + // wäre schlicht falsch. Was dann gilt, sagt die Seite an anderer Stelle. + [$user] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended'); + + Livewire::actingAs($user)->test(Dashboard::class) + ->assertDontSee(__('dashboard.ending.title')); +}); + +it('nennt keine Restzeit, die schon abgelaufen ist', function () { + // Der Fall zwischen den Stühlen: das Laufzeitende ist vorbei, aber der + // stündliche Lauf hat die Instanz noch nicht auf `ended` gestellt. Eine + // Zeile "noch -3 Stunden" ist die Sorte Fehler, die niemand meldet und + // jeder sieht. + // + // Korrektur gegenüber dem Zettel: `not->toContain('-')` über das ganze + // Dokument trägt nicht — ein Datum und eine CSS-Klasse enthalten ebenso + // Bindestriche, und die Prüfung wäre aus dem falschen Grund rot oder + // grün. Sie zielt stattdessen auf die Restzeit selbst: der Streifen darf + // gar nicht erst erscheinen, wenn das Laufzeitende vorbei ist — unabhängig + // davon, ob der Status das schon nachvollzogen hat. Genau das ist die + // Prüfung, die eine Implementierung ertappt, die sich allein auf den + // Status verlässt und das Datum ignoriert. + [$user] = gekuendigteInstanz(endetIn: now()->subHours(3)); + + Livewire::actingAs($user)->test(Dashboard::class) + ->assertDontSee(__('dashboard.ending.title')); +}); + +it('nennt die Restzeit in ganzen Tagen, solange mehr als ein Tag bleibt', function () { + // Nicht im Zettel geprüft, aber ausdrücklich gefordert: "noch 12 Tage" + // liest sich, "noch 11 Tage 7 Stunden" nicht — abgerundet auf ganze Tage. + [$user] = gekuendigteInstanz(endetIn: now()->addDays(3)->addHours(5)); + + Livewire::actingAs($user)->test(Dashboard::class) + ->assertSee(trans_choice('dashboard.ending.remaining_days', 3)); +}); + +it('zaehlt am letzten Tag in Stunden, nicht mehr in Tagen', function () { + [$user] = gekuendigteInstanz(endetIn: now()->addHours(5)); + + Livewire::actingAs($user)->test(Dashboard::class) + ->assertSee(trans_choice('dashboard.ending.remaining_hours', 5)); +}); + +it('traegt die Antwort zum Export und laesst sie aendern', function () { + [$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12)); + $instance->update(['export_wish' => false]); + + Livewire::actingAs($user)->test(Dashboard::class) + ->assertSee(__('dashboard.ending.export_no')) + ->call('setExportWish', true); + + expect($instance->fresh()->export_wish)->toBeTrue(); +}); + +it('laesst die Antwort nach dem Laufzeitende nicht mehr aendern', function () { + // Aufgabe 4: danach ist die Instanz `ended`, die Adresse eingezogen, und + // ob ein Export vorbereitet wurde, ist entschieden. Ein Aufruf, der dort + // noch etwas verspricht, wäre die nächste Attrappe — geprüft direkt gegen + // die Methode, nicht nur gegen den (dann ohnehin nicht mehr sichtbaren) + // Schalter im Streifen: setExportWish() ist über /livewire/update auch am + // Formular vorbei erreichbar. + [$user, $instance] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended'); + $instance->update(['export_wish' => false]); + + Livewire::actingAs($user)->test(Dashboard::class) + ->call('setExportWish', true); + + expect($instance->fresh()->export_wish)->toBeFalse(); +});