docs: add sprint planning — dependency diagram + specs for all 8 stories

Analyzes all Taiga stories, maps hard/soft dependencies, and writes
full specs (Goal, Data Model, Components, Verification, Open Questions)
for every story before any implementation begins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:53:24 +02:00
co-authored by Claude Sonnet 4.6
commit 1070ba3ac1
10 changed files with 1239 additions and 0 deletions
@@ -0,0 +1,151 @@
# Spec: 07 · Improve Fruit Overview
**Taiga Story:** #7 · `07-improve-fruit-overview`
**Status:** Ready
**Branch:** `feature/07-improve-fruit-overview`
**Depends on:** `02-manage-fruits` (fruit_images), `04-fruit-publications` (publication_fruit_images)
---
## 1. Goal
Replace the plain fruit list with styled cards showing title, OSDB ID, type, synonyms, and an auto-cropped thumbnail. Thumbnails are generated at upload time in Go (stdlib only) and stored as a separate DB column. A backfill endpoint handles existing images.
---
## 2. Background / Scope / Context / Constraints
- Thumbnail selection priority:
1. Fruit's own image with `image_type = 'fruit'` (first found)
2. First `publication_fruit_images` entry linked to the fruit
3. No image → no thumbnail (graceful empty state)
- Thumbnail constraint: image may be scaled down but MUST NOT be cropped (aspect ratio preserved). Empty borders allowed — no cutting.
- Auto-crop algorithm: find bounding box of non-background content → add small padding → crop → re-encode as JPEG. Falls back to original if image decoding fails.
- Thumbnail stored in `thumbnail_data BYTEA` column (nullable) on `fruit_images` and `publication_fruit_images`.
- New serve endpoints for thumbnails, falling back to full `data` if `thumbnail_data` is NULL.
- Backfill: HTTP endpoint `POST /api/v1/admin/backfill-thumbnails` (or CLI command) processes all rows without `thumbnail_data`.
- No external libraries (OpenCV etc.) — Go stdlib only (`image/jpeg`, `image/png`).
---
## 3. Necessary Refactorings
- Add `thumbnail_data` migration for both image tables.
- On-upload path for both `fruit_images` and `publication_fruit_images`: run crop before INSERT.
- `ListFruits` response: include `thumbnail_url` pointing to new `/thumbnail` endpoint.
- `FruitList.vue`: replace table rows with card components.
---
## 4. Related Data Model
```sql
-- Migration: 000004_add_thumbnails.up.sql
ALTER TABLE fruit_images
ADD COLUMN thumbnail_data BYTEA;
ALTER TABLE publication_fruit_images
ADD COLUMN thumbnail_data BYTEA;
```
---
## 5. Affected Places / Related Components
### Backend
| File | Change |
|------|--------|
| `backend/internal/imaging/crop.go` | Auto-crop function: `CropToContent(data []byte) ([]byte, error)` |
| `backend/internal/repository/fruit_repo.go` | `CreateImage`: call `CropToContent`, store both `data` + `thumbnail_data` |
| `backend/internal/repository/publication_repo.go` | Same for `publication_fruit_images` |
| `backend/internal/handler/fruit_handler.go` | Add `GET /fruits/:id/images/:imageId/thumbnail` |
| `backend/internal/handler/publication_handler.go` | Add `GET /publications/:id/fruit-images/:imgId/thumbnail` |
| `backend/internal/handler/admin_handler.go` | `POST /api/v1/admin/backfill-thumbnails` |
| `backend/cmd/server/router.go` | Register new routes |
| `migrations/000004_add_thumbnails.up.sql` | ALTER TABLE |
### Crop Algorithm (Go stdlib)
```go
// Pseudocode — no external deps
func CropToContent(src []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil { return src, nil } // fallback: return original
bounds := img.Bounds()
// Detect background: sample corner pixel as bg color
bgColor := img.At(bounds.Min.X, bounds.Min.Y)
// Find bounding box of non-bg pixels
minX, minY, maxX, maxY := findContentBounds(img, bgColor, threshold=10)
// Add padding (e.g. 5px each side)
// Crop to bounding box
// Encode as JPEG
return encodedBytes, nil
}
```
### Frontend
| File | Change |
|------|--------|
| `frontend/src/views/FruitList.vue` | Replace rows with `<FruitCard>` components |
| `frontend/src/components/FruitCard.vue` | Card: thumbnail + name + OSDB ID + type + synonyms |
| `frontend/src/api/fruits.ts` | `listFruits` response includes `thumbnail_url` |
### API Changes
| New endpoint | Description |
|-------------|-------------|
| `GET /api/v1/fruits/:id/images/:imageId/thumbnail` | Serves `thumbnail_data`, falls back to `data` |
| `GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail` | Same |
| `POST /api/v1/admin/backfill-thumbnails` | Processes all rows missing `thumbnail_data` |
`ListFruits` response shape per fruit:
```json
{
"id": 1,
"name": "Boskop",
"osdb_number": "a001",
"fruit_type": "Apfelsorten",
"synonyms": ["Roter Boskop"],
"thumbnail_url": "/api/v1/fruits/1/images/3/thumbnail"
}
```
---
## 6. Out of Scope
- Interactive image editor / manual crop
- WebP encoding
- CDN / object storage for images
- Pagination of fruit list
---
## 7. Verification
| Check | Expected |
|-------|---------|
| Upload fruit image → `fruit_images.thumbnail_data` is non-null | Pass |
| `GET /thumbnail` endpoint → responds with JPEG, smaller than original | Pass |
| Backfill endpoint: runs on existing rows without thumbnail_data | All rows populated |
| `GET /api/v1/fruits` → each fruit with image has `thumbnail_url` | Pass |
| Fruit with no image → `thumbnail_url: null` | Graceful |
| Thumbnail falls back to publication image if no direct image | Correct priority |
| `FruitList.vue` shows cards with thumbnails | Visual check |
| Image with undecodable format → fallback = original stored, no error | Pass |
| `go test ./...` including imaging unit tests | Green |
---
## 8. Open Questions
| # | Question | Impact |
|---|----------|--------|
| 1 | Background detection threshold: what color tolerance? | Crop quality |
| 2 | Padding around cropped content: 5px? 10px? | Visual quality |
| 3 | Should backfill endpoint be idempotent (skip rows already having thumbnail)? | Yes — only process WHERE thumbnail_data IS NULL |
| 4 | Max concurrent backfill workers? | Performance |
| 5 | Thumbnail max dimensions (e.g. 300×300 max)? | UX + storage |