Habit and reminder apps feel alive because they remember your progress and nudge you at the right time. This project covers two things: a clean way to calculate streaks from dates, and local notifications, which the app schedules on the phone itself with no server.
Concepts in this project
- Date keys: store each completed day as a text key such as
2026-11-04. Text keys avoid time-zone bugs from storing full timestamps. - Streak function: a plain function counts consecutive days ending today, or yesterday if today is not done yet, so a streak is not shown as broken during the day.
- Local notifications:
scheduleNotificationAsyncwith a daily trigger fires every day at a set time, even when the app is closed. - Permission: ask with
requestPermissionsAsync. On Android 8 and later you also create a notification channel. - Notification handler: tells the app how to show notifications that arrive while it is open.
- Limits: remote push notifications (sent from a server) need more setup and are outside this lesson. Notifications can behave differently in Expo Go. For a faithful test, use a development build.
Create the project
- Use a blank Expo TypeScript project.
- Install:
npx expo install expo-notifications @react-native-async-storage/async-storage. - Add the plugin entry for expo-notifications to
app.json(see lesson 10's permissions file). - Add
habits.tsandreminders.ts, and replaceApp.tsx.
`habits.ts`
import AsyncStorage from "@react-native-async-storage/async-storage";
export type Habit = { id: string; name: string; doneDates: string[] };
const KEY = "habits.v1";
export const dayKey = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
// Counts consecutive days ending today (or yesterday if today is not done yet).
export function streak(doneDates: string[], today = new Date()): number {
const set = new Set(doneDates);
const cursor = new Date(today);
if (!set.has(dayKey(cursor))) cursor.setDate(cursor.getDate() - 1);
let count = 0;
while (set.has(dayKey(cursor))) {
count++;
cursor.setDate(cursor.getDate() - 1);
}
return count;
}
export async function loadHabits(): Promise<Habit[]> {
const raw = await AsyncStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Habit[]) : [];
}
export const saveHabits = (h: Habit[]) => AsyncStorage.setItem(KEY, JSON.stringify(h));
`reminders.ts`
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
export async function scheduleDailyReminder(hour: number, minute: number): Promise<boolean> {
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("habits", {
name: "Habit reminders",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const current = await Notifications.getPermissionsAsync();
const status = current.granted ? current : await Notifications.requestPermissionsAsync();
if (!status.granted) return false;
await Notifications.cancelAllScheduledNotificationsAsync();
await Notifications.scheduleNotificationAsync({
content: { title: "Habit check-in", body: "Time to do your habits for today." },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
channelId: "habits",
},
});
return true;
}
export const cancelReminders = () => Notifications.cancelAllScheduledNotificationsAsync();
`App.tsx`
import { useEffect, useState } from "react";
import { SafeAreaView, View, Text, TextInput, Pressable, FlatList, Switch, Alert, StyleSheet } from "react-native";
import { Habit, dayKey, loadHabits, saveHabits, streak } from "./habits";
import { cancelReminders, scheduleDailyReminder } from "./reminders";
export default function App() {
const [habits, setHabits] = useState<Habit[]>([]);
const [name, setName] = useState("");
const [remind, setRemind] = useState(false);
const [ready, setReady] = useState(false);
const today = dayKey(new Date());
useEffect(() => {
loadHabits().then((h) => {
setHabits(h);
setReady(true);
});
}, []);
useEffect(() => {
if (ready) saveHabits(habits);
}, [habits, ready]);
const add = () => {
if (!name.trim()) return;
setHabits((h) => [...h, { id: Date.now().toString(), name: name.trim(), doneDates: [] }]);
setName("");
};
const toggleToday = (id: string) =>
setHabits((h) =>
h.map((x) =>
x.id !== id
? x
: {
...x,
doneDates: x.doneDates.includes(today)
? x.doneDates.filter((d) => d !== today)
: [...x.doneDates, today],
}
)
);
const toggleReminder = async (on: boolean) => {
if (!on) {
await cancelReminders();
setRemind(false);
return;
}
const ok = await scheduleDailyReminder(20, 0);
if (!ok) Alert.alert("Notifications are off", "Allow notifications in Settings to get a daily reminder at 8 pm.");
setRemind(ok);
};
return (
<SafeAreaView style={styles.screen}>
<Text style={styles.title}>Habits</Text>
<View style={styles.row}>
<Text style={styles.grow}>Daily reminder at 8 pm</Text>
<Switch value={remind} onValueChange={toggleReminder} />
</View>
<View style={styles.row}>
<TextInput style={[styles.input, styles.grow]} value={name} onChangeText={setName} placeholder="New habit" />
<Pressable style={styles.add} onPress={add}>
<Text style={styles.addText}>Add</Text>
</Pressable>
</View>
<FlatList
data={habits}
keyExtractor={(h) => h.id}
ListEmptyComponent={<Text style={styles.empty}>Add a habit to start a streak.</Text>}
renderItem={({ item }) => {
const done = item.doneDates.includes(today);
return (
<View style={styles.card}>
<View style={styles.grow}>
<Text style={styles.habit}>{item.name}</Text>
<Text style={styles.streak}>{streak(item.doneDates)} day streak</Text>
</View>
<Pressable style={[styles.done, done && styles.doneOn]} onPress={() => toggleToday(item.id)}>
<Text style={done ? styles.doneTextOn : undefined}>{done ? "Done" : "Mark done"}</Text>
</Pressable>
</View>
);
}}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 20, backgroundColor: "#f5f6fa" },
title: { fontSize: 28, fontWeight: "700", marginBottom: 12 },
row: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 12 },
grow: { flex: 1 },
input: { backgroundColor: "#fff", borderRadius: 10, padding: 12, borderWidth: 1, borderColor: "#d0d4dc" },
add: { backgroundColor: "#3b5bdb", paddingVertical: 12, paddingHorizontal: 16, borderRadius: 10 },
addText: { color: "#fff", fontWeight: "600" },
empty: { textAlign: "center", color: "#888", marginTop: 30 },
card: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", padding: 14, borderRadius: 12, marginBottom: 8 },
habit: { fontSize: 17, fontWeight: "600" },
streak: { color: "#2b8a3e", marginTop: 2 },
done: { paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: "#e3e6ee" },
doneOn: { backgroundColor: "#2b8a3e" },
doneTextOn: { color: "#fff", fontWeight: "600" },
});
How it works
dayKeymakes the date key.streakwalks backwards from today while days are in the set.- Marking done toggles today's key in the habit's list. The streak is calculated while rendering, not stored.
- The reminder switch asks for permission, cancels old reminders and schedules one daily at 8 pm. Turning it off cancels all of them.
- If permission is refused, the switch stays off and an alert explains how to fix it.
Expected output
The screen shows Habits, a reminder switch and a box to add habits. Add Read and Walk. Press Mark done on Read: the button turns green and says Done, and the line reads "1 day streak". Press it again to undo. Turn on the reminder: a permission request appears. Allow it. At 8 pm each day you receive a notification titled Habit check-in. To test faster, change the hour and minute to a time a minute or two ahead. Close and reopen the app: habits and streaks remain.
Your task
- Build the app, add two habits and mark one done.
- Schedule the reminder for two minutes ahead and confirm that it arrives.
- Deny the permission once and check that the app handles it.
- Run the streak tests from the testing lesson.
How to check it worked
- Streaks and habits persist.
- The reminder arrives on a device.
- You can explain why dates are stored as text keys.