Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ ones are marked like "v1.0.0-fork".

### Changed

* **Word-status model is now a single source of truth** (#238, Phase 1): the
`TermStatus` value object is promoted to the authoritative model — it owns the
abbreviation, CSS class, light-theme colour, display order and predicates, and
exposes `TermStatus::definitions()` (the one ordered status table). The scattered
`[1,2,3,4,5,98,99]` literals and `=== 5 || === 99` / `=== 98` checks across the
Review, Vocabulary and Admin modules now call `TermStatus::isValid()` /
`values()` / `isKnownValue()` / `isIgnoredValue()`, and `TermStatusService` /
`StatusHelper` delegate to the VO instead of redefining the tables. The model is
exposed once to the frontend via `GET /api/v1/settings/status-definitions`, and
the duplicated TypeScript status tables (`text_status_chart.ts`,
`texts_grouped_app.ts`, `html_utils.ts`, `term_edit_modal.ts`, the `app_data.ts`
proxy) now resolve from a single `shared/stores/statuses.ts`. No behaviour or
wire-format change; the per-status scheduling formulas are untouched (they belong
to the proposed Phase 2, FSRS-aligned scheduling).
* **Single `data_hex` word identity in the reading view** (#237): every word
occurrence now carries its term identity solely as a `data_hex` attribute,
and the redundant `TERM<hex>` CSS class has been dropped. The token is now a
Expand Down
60 changes: 45 additions & 15 deletions docs-src/developer/term-status-fsrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ description: Centralize the scattered word-status model into a single source of

# Proposal: Term Status Model + FSRS Scheduling

**Status:** Proposed — deferred until after the next release. Larger than a cleanup;
the FSRS part is an architectural change worth landing on its own.
**Status:** **Phase 1 implemented** (#238). Phase 2 (FSRS scheduling) remains
proposed — deferred, and gated on the product decisions in
[Trade-offs & open questions](#trade-offs-open-questions). The FSRS part is an
architectural change worth landing on its own.
Tracked in [issue #238](https://github.com/HugoFara/lwt/issues/238).

A design proposal, not shipped work.
Phase 1 (status as a single source of truth) is shipped; the rest is a design
proposal, not shipped work.

## Problem

Expand Down Expand Up @@ -43,18 +46,45 @@ So two distinct concerns are conflated in one integer: **how familiar a word is*
2. **Align scheduling with Anki/FSRS** by separating *display familiarity* from
*memory state*, replacing the Leitner score formulas with a principled scheduler.

## Phase 1 — Status as a single source of truth

- Promote `TermStatus` (value object) to the authoritative model: hold `value`,
`label`, `abbreviation`, `cssClass`, `colourHex`, `order`, and predicates
(`isKnown()`, `isIgnored()`, `isLearning()`). Fold `TermStatusService`'s scattered
helpers and `StatusHelper`'s magic-range arithmetic into it.
- Replace the 11+ PHP literal arrays / inline comparisons with the value object.
- Expose it **once** to the frontend (bootstrap payload or
`GET /api/v1/settings/status-definitions`); delete the ~6 duplicated TS tables in
favour of a single `shared/stores/statuses.ts`.

This stands alone and is worth doing regardless of Phase 2.
## Phase 1 — Status as a single source of truth ✅ implemented

This stands alone and was worth doing regardless of Phase 2.

### What shipped

- **`TermStatus` is now the authoritative model.** It holds the abbreviation, CSS
class, light-theme colour, order and predicates, and exposes
`TermStatus::definitions()` — the single ordered table of `value / name / abbr /
cssClass / colour / order / isKnown / isLearning / isIgnored`. `isValid()`,
`values()` and `isKnownValue()/isIgnoredValue()/isLearningValue()` (non-throwing,
safe on unvalidated input) round it out.
- **`TermStatusService` and `StatusHelper` delegate to the VO.** `getStatuses()`,
`getStatusColor()`, `isValidStatus()` and the `is{Learning,Known,Ignored}Status()`
helpers are now thin adapters; the duplicated name/abbr/colour tables are gone.
(The scheduling members — `SCORE_FORMULA_*`, `calculateScore()`,
`makeScoreRandomInsertUpdate()` — were left untouched; they are Phase 2.)
- **The scattered literals are gone.** `in_array($status, [1,2,3,4,5,98,99])`,
`array_fill_keys([1,2,3,4,5,98,99], …)` and `=== 5 || === 99` / `=== 98` checks
across the Review, Vocabulary and Admin modules now call `TermStatus::isValid()`,
`TermStatus::values()` and `isKnownValue()/isIgnoredValue()`.
- **Exposed once to the frontend** via `GET /api/v1/settings/status-definitions`
(returns `TermStatus::definitions()`).
- **One frontend store.** `shared/stores/statuses.ts` is the single TS source for
status labels/abbr/order/class (localized through the shared `common.status_*`
i18n keys, so PHP and TS resolve identical text). The duplicated `STATUS_LABELS` /
`STATUS_ORDER` tables in `text_status_chart.ts`, `texts_grouped_app.ts` and
`html_utils.ts`, the `term_edit_modal.ts` option list, and the `app_data.ts`
`statuses` proxy now all resolve from it.

### Deliberately left for a follow-up

- The two Chart.js **colour palettes** (`statistics_charts.ts`,
`text_status_chart.ts`) diverge from each other and from the CSS
`--lwt-status*` variables; unifying them is a *visual* change, kept out of this
cleanup. The reading view itself already single-sources its colours from CSS.
- `word_popover.ts` / `multi_word_modal.ts` keep their local status lists — those
encode popover-specific *presentation* (Bulma button colours, short `Known` /
`Ignore` badges) rather than the status model.

## Phase 2 — FSRS-aligned scheduling

Expand Down
6 changes: 6 additions & 0 deletions src/Modules/Admin/Http/AdminApiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Lwt\Shared\Infrastructure\Database\Settings;
use Lwt\Shared\Infrastructure\Globals;
use Lwt\Modules\Admin\Domain\SettingDefinitions;
use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus;
use Lwt\Shared\Http\ApiRoutableInterface;
use Lwt\Shared\Http\ApiRoutableTrait;
use Lwt\Shared\Infrastructure\Http\JsonResponse;
Expand All @@ -48,6 +49,11 @@ public function routeGet(array $fragments, array $params): JsonResponse
if ($frag1 === 'theme-path') {
return Response::success($this->formatThemePath((string) ($params['path'] ?? '')));
}
if ($frag1 === 'status-definitions') {
// Single source of truth for the word-status model, consumed by
// the frontend status store. See TermStatus::definitions().
return Response::success(['statuses' => TermStatus::definitions()]);
}
return Response::error('Endpoint Not Found: ' . $frag1, 404);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
namespace Lwt\Modules\Admin\Infrastructure;

use Lwt\Shared\Infrastructure\Database\QueryBuilder;
use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus;

/**
* MySQL repository for statistics queries.
Expand Down Expand Up @@ -123,7 +124,7 @@ public function getTermActivityByDay(): array
$changed = (int) ($record['Changed'] ?? 0);
$value = (int) ($record['value'] ?? 0);

if ($status == 5 || $status == 99) {
if (TermStatus::isKnownValue($status)) {
if (!isset($termKnown[$lgId][$changed])) {
$termKnown[$lgId][$changed] = 0;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Modules/Review/Application/UseCases/SubmitAnswer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Lwt\Modules\Review\Domain\ReviewRepositoryInterface;
use Lwt\Modules\Review\Domain\ReviewSession;
use Lwt\Modules\Review\Infrastructure\SessionStateManager;
use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus;

/**
* Use case for submitting an answer during review.
Expand Down Expand Up @@ -209,7 +210,7 @@ private function calculateStatusChange(int $oldStatus, int $newStatus): int
*/
private function isValidStatus(int $status): bool
{
return in_array($status, [1, 2, 3, 4, 5, 98, 99], true);
return TermStatus::isValid($status);
}

/**
Expand Down
8 changes: 5 additions & 3 deletions src/Modules/Review/Domain/ReviewWord.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

namespace Lwt\Modules\Review\Domain;

use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus;

/**
* Entity representing a word being reviewed.
*
Expand Down Expand Up @@ -135,7 +137,7 @@ public function getSentenceForDisplay(): string
*/
public function isLearning(): bool
{
return $this->status >= 1 && $this->status <= 5;
return TermStatus::isLearningValue($this->status);
}

/**
Expand All @@ -145,7 +147,7 @@ public function isLearning(): bool
*/
public function isWellKnown(): bool
{
return $this->status === 99;
return $this->status === TermStatus::WELL_KNOWN;
}

/**
Expand All @@ -155,7 +157,7 @@ public function isWellKnown(): bool
*/
public function isIgnored(): bool
{
return $this->status === 98;
return TermStatus::isIgnoredValue($this->status);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/Modules/Review/Http/ReviewApiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Lwt\Shared\Infrastructure\Language\LanguagePresets;
use Lwt\Modules\Vocabulary\Application\Services\ExportService;
use Lwt\Modules\Vocabulary\Application\Helpers\StatusHelper;
use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus;
use Lwt\Shared\Http\ApiRoutableInterface;
use Lwt\Shared\Http\ApiRoutableTrait;
use Lwt\Shared\Infrastructure\Http\JsonResponse;
Expand Down Expand Up @@ -447,7 +448,7 @@ public function updateReviewStatus(int $wordId, ?int $status, ?int $change): arr
{
if ($status !== null) {
// Explicit status - validate it
if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) {
if (!TermStatus::isValid($status)) {
return ['error' => 'Invalid status value'];
}
$result = $this->reviewFacade->submitAnswer($wordId, $status);
Expand Down
3 changes: 2 additions & 1 deletion src/Modules/Vocabulary/Application/Helpers/StatusHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

use Lwt\Shared\UI\Helpers\IconHelper;
use Lwt\Modules\Vocabulary\Application\Services\TermStatusService;
use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus;

/**
* Helper class for word status-related display logic.
Expand Down Expand Up @@ -111,7 +112,7 @@ public static function makeClassFilter(int|string $status): string
return '';
}

$allStatuses = [1, 2, 3, 4, 5, 98, 99];
$allStatuses = TermStatus::values();
$includedStatuses = self::getIncludedStatuses($status);

// Build :not() selectors for statuses NOT in the filter
Expand Down
60 changes: 19 additions & 41 deletions src/Modules/Vocabulary/Application/Services/TermStatusService.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,39 +82,24 @@ class TermStatusService
/**
* Return an associative array of all possible statuses.
*
* Names are localized via the i18n translator (common.status_*).
* Abbreviations are language-neutral: "1".."5" for learning levels,
* empty string for 98/99 — display code should fall back to the
* full localized name when the abbreviation is empty.
* Thin adapter over {@see TermStatus::definitions()} (the single source
* of truth): names are localized via the i18n translator, abbreviations
* are language-neutral ("1".."5" for learning levels, empty for 98/99 —
* display code falls back to the full name when the abbreviation is empty).
*
* @return array<int, array{abbr: string, name: string}>
* Keys are 1, 2, 3, 4, 5, 98, 99.
*/
public static function getStatuses(): array
{
$learning = self::translateStatus('common.status_learning', 'Learning');
$learned = self::translateStatus('common.status_learned', 'Learned');
$wellKnown = self::translateStatus('common.status_well_known', 'Well Known');
$ignored = self::translateStatus('common.status_ignored', 'Ignored');
return [
TermStatus::NEW => ["abbr" => "1", "name" => $learning],
TermStatus::LEARNING_2 => ["abbr" => "2", "name" => $learning],
TermStatus::LEARNING_3 => ["abbr" => "3", "name" => $learning],
TermStatus::LEARNING_4 => ["abbr" => "4", "name" => $learning],
TermStatus::LEARNED => ["abbr" => "5", "name" => $learned],
TermStatus::WELL_KNOWN => ["abbr" => "", "name" => $wellKnown],
TermStatus::IGNORED => ["abbr" => "", "name" => $ignored],
];
}

/**
* Resolve a status translation key, falling back to the English label
* when the translator is unavailable (e.g. during a Container reset).
*/
private static function translateStatus(string $key, string $fallback): string
{
$value = __($key);
return ($value === $key) ? $fallback : $value;
$statuses = [];
foreach (TermStatus::definitions() as $definition) {
$statuses[$definition['value']] = [
'abbr' => $definition['abbr'],
'name' => $definition['name'],
];
}
return $statuses;
}

/**
Expand Down Expand Up @@ -147,7 +132,7 @@ public static function makeScoreRandomInsertUpdate(string $type): string
*/
public static function isValidStatus(int $status): bool
{
return isset(self::getStatuses()[$status]);
return TermStatus::isValid($status);
}

/**
Expand Down Expand Up @@ -225,16 +210,9 @@ public static function calculateTomorrowScore(int $status, int $daysSinceChange)
*/
public static function getStatusColor(int $status): string
{
return match ($status) {
TermStatus::NEW => 'status1',
TermStatus::LEARNING_2 => 'status2',
TermStatus::LEARNING_3 => 'status3',
TermStatus::LEARNING_4 => 'status4',
TermStatus::LEARNED => 'status5',
TermStatus::IGNORED => 'status98',
TermStatus::WELL_KNOWN => 'status99',
default => 'status0',
};
return TermStatus::isValid($status)
? TermStatus::fromInt($status)->cssClass()
: 'status0';
}

/**
Expand Down Expand Up @@ -290,7 +268,7 @@ public static function getKnownStatuses(): array
*/
public static function isLearningStatus(int $status): bool
{
return $status >= 1 && $status <= 5;
return TermStatus::isLearningValue($status);
}

/**
Expand All @@ -302,7 +280,7 @@ public static function isLearningStatus(int $status): bool
*/
public static function isKnownStatus(int $status): bool
{
return $status === TermStatus::LEARNED || $status === TermStatus::WELL_KNOWN;
return TermStatus::isKnownValue($status);
}

/**
Expand All @@ -314,6 +292,6 @@ public static function isKnownStatus(int $status): bool
*/
public static function isIgnoredStatus(int $status): bool
{
return $status === TermStatus::IGNORED;
return TermStatus::isIgnoredValue($status);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
namespace Lwt\Modules\Vocabulary\Application\Services;

use Lwt\Modules\Vocabulary\Domain\Term;
use Lwt\Modules\Vocabulary\Domain\ValueObject\TermStatus;
use Lwt\Modules\Vocabulary\Infrastructure\MySqlTermRepository;
use Lwt\Shared\Infrastructure\Database\Connection;
use Lwt\Shared\Infrastructure\Database\UserScopedQuery;
Expand Down Expand Up @@ -191,7 +192,7 @@ public function getWordFamilyDetails(int $termId): ?array
);

$terms = [];
$statusCounts = array_fill_keys([1, 2, 3, 4, 5, 98, 99], 0);
$statusCounts = array_fill_keys(TermStatus::values(), 0);
$totalOccurrences = 0;

foreach ($members as $member) {
Expand Down Expand Up @@ -283,14 +284,14 @@ private function buildSingleTermFamily(int $termId): ?array
'stats' => [
'formCount' => 1,
'statusCounts' => array_merge(
array_fill_keys([1, 2, 3, 4, 5, 98, 99], 0),
array_fill_keys(TermStatus::values(), 0),
[$status => 1]
),
'averageStatus' => $status <= 5 ? (float) $status : 0.0,
'totalOccurrences' => $occurrences,
'knownCount' => $status === 5 || $status === 99 ? 1 : 0,
'knownCount' => TermStatus::isKnownValue($status) ? 1 : 0,
'learningCount' => $status >= 1 && $status <= 4 ? 1 : 0,
'ignoredCount' => $status === 98 ? 1 : 0,
'ignoredCount' => TermStatus::isIgnoredValue($status) ? 1 : 0,
],
];
}
Expand Down Expand Up @@ -322,7 +323,7 @@ private function getWordOccurrenceCount(int $wordId): int
*/
public function updateWordFamilyStatus(int $languageId, string $lemmaLc, int $status): int
{
if (!in_array($status, [1, 2, 3, 4, 5, 98, 99])) {
if (!TermStatus::isValid($status)) {
return 0;
}

Expand Down Expand Up @@ -484,7 +485,7 @@ public function getSuggestedFamilyUpdate(int $termId, int $newStatus): array

// Find family members with lower status (for "known" updates)
// or higher status (for "learning" updates)
if ($newStatus === 99 || $newStatus === 5) {
if (TermStatus::isKnownValue($newStatus)) {
// Marked as known - suggest updating forms with lower status
$bindings = [$languageId, $lemmaLc, $termId];
$affected = Connection::preparedFetchAll(
Expand Down Expand Up @@ -539,7 +540,7 @@ public function getSuggestedFamilyUpdate(int $termId, int $newStatus): array
*/
public function bulkUpdateTermStatus(array $termIds, int $status): int
{
if (empty($termIds) || !in_array($status, [1, 2, 3, 4, 5, 98, 99])) {
if (empty($termIds) || !TermStatus::isValid($status)) {
return 0;
}

Expand Down
Loading