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:
@@ -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 |
|
||||
Reference in New Issue
Block a user