Files
osdb-1-claude-opusplan/docs/planning/specs/07-improve-fruit-overview.md
T
juliaandClaude Sonnet 4.6 1070ba3ac1 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>
2026-06-16 11:53:24 +02:00

5.7 KiB
Raw Blame History

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.

-- 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;

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)

// 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:

{
  "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