๐ About This Code Showcase
This curated code walkthrough demonstrates how Skyread generates daily horoscope readings using Google Gemini AI with structured JSON schema output, manages bilingual support, and caches readings in localStorage for a fast return-visit experience.
This showcase focuses on the two most important parts of the codebase: the Gemini AI service layer (geminiService.ts) and the main app logic (App.tsx).
๐๏ธ Project File Structure
skyread/
โโโ src/
โ โโโ App.tsx
โ โโโ constants.ts
โ โโโ main.tsx
โ โโโ index.css
โ โโโ services/
โ โโโ geminiService.ts
โโโ index.html
โโโ package.json
โโโ vite.config.ts
โโโ tsconfig.json
โโโ .env.example
๐ค Core AI Layer: geminiService.ts
This is the heart of the app โ the Gemini AI service that generates a structured horoscope for a given zodiac sign and language. It uses schema-enforced JSON output to guarantee consistent, typed responses.
import { GoogleGenAI, Type, ThinkingLevel } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || "" });
export interface HoroscopeData {
date: string;
reading: string;
skyExplainer: string;
snapshot: {
planet: string;
position: string;
influence: string;
}[];
}
export async function generateDailyHoroscope(
sign: string,
language: 'en' | 'zh' = 'en'
): Promise<HoroscopeData> {
const today = new Date().toISOString().split('T')[0];
const langPrompt = language === 'zh'
? "in Mandarin Chinese (Simplified)"
: "in English";
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: `Generate a daily horoscope for ${sign} for the date ${today} ${langPrompt}.
Include:
1. A personalized daily reading (about 100 words).
2. A "Sky Explainer" that identifies 2-3 real planetary movements happening today
and explains in plain language why they affect ${sign}.
3. A "Sky Snapshot" โ list of 3 key planets, their current astrological position
(e.g., "Mars in Leo"), and their brief influence.
The tone should be grounded, credible, and atmospheric.`,
config: {
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
date: { type: Type.STRING },
reading: { type: Type.STRING },
skyExplainer: { type: Type.STRING },
snapshot: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
planet: { type: Type.STRING },
position: { type: Type.STRING },
influence: { type: Type.STRING }
},
required: ["planet", "position", "influence"]
}
}
},
required: ["date", "reading", "skyExplainer", "snapshot"]
}
}
});
return JSON.parse(response.text);
}
Why this approach works: By passing a responseSchema to Gemini, the API guarantees the response will always conform to the declared shape. This eliminates defensive JSON parsing, removes the need for fallback logic, and ensures the TypeScript types match reality at runtime.
โ๏ธ App State & Screen Management: App.tsx
The main app manages three screens (welcome โ home โ archive), user preferences, reading caching, and language toggling โ all via React state and localStorage.
type Screen = 'welcome' | 'home' | 'archive';
type Language = 'en' | 'zh';
export default function App() {
const [userSign, setUserSign] = useState<string | null>(
localStorage.getItem('skyread_sign')
);
const [language, setLanguage] = useState<Language>(
() => (localStorage.getItem('skyread_lang') as Language) || 'en'
);
const [todayReading, setTodayReading] = useState<HoroscopeData | null>(null);
const [archive, setArchive] = useState<HoroscopeData[]>(() => {
const saved = localStorage.getItem('skyread_archive');
return saved ? JSON.parse(saved) : [];
});
const fetchDailyReading = async (sign: string, lang: Language) => {
const today = new Date().toISOString().split('T')[0];
const isCachedInLang = todayReading?.date === today && (
lang === 'zh'
? /[\u4e00-\u9fa5]/.test(todayReading.reading)
: !/[\u4e00-\u9fa5]/.test(todayReading.reading)
);
if (isCachedInLang) return;
setLoading(true);
try {
const data = await generateDailyHoroscope(sign, lang);
setTodayReading(data);
const newArchive = [
data,
...archive.filter(item => item.date !== today || item.reading !== data.reading)
].slice(0, 30);
setArchive(newArchive);
localStorage.setItem('skyread_archive', JSON.stringify(newArchive));
} finally {
setLoading(false);
}
};
const toggleLanguage = () => {
const newLang = language === 'en' ? 'zh' : 'en';
setLanguage(newLang);
localStorage.setItem('skyread_lang', newLang);
setTodayReading(null);
};
}
๐ Animated Loading State
During AI generation, the app cycles through themed loading messages every 2.5 seconds to create an immersive "consulting the stars" experience rather than a generic spinner.
const TRANSLATIONS = {
en: {
loadingMessages: [
"Consulting the celestial charts...",
"Tracing planetary alignments...",
"Decoding cosmic vibrations...",
"Mapping the current sky...",
"Translating the language of stars...",
"Gleaning insights from the void..."
]
},
zh: {
loadingMessages: [
"ๆญฃๅจๆฅ้
ๅคฉไฝๅพ...",
"ๆญฃๅจ่ฟฝ่ธช่กๆๆๅ...",
"ๆญฃๅจ่งฃ็ ๅฎๅฎๅ
ฑๆฏ...",
"ๆญฃๅจ็ปๅถๅฝๅๆ็ฉบ...",
"ๆญฃๅจ็ฟป่ฏๆไน่ฏญ่จ...",
"ๆญฃๅจไป่็ฉบไธญ่ทๅๆด่ง..."
]
}
};
useEffect(() => {
let interval: NodeJS.Timeout;
if (loading) {
let i = 0;
interval = setInterval(() => {
setLoadingMessage(t.loadingMessages[i % t.loadingMessages.length]);
i++;
}, 2500);
}
return () => clearInterval(interval);
}, [loading, language]);