Vue.js course Β· Module 11: Pinia

Project: NOVA LAB Resource System

2 min read
In this lesson3

Time to apply all Pinia knowledge in one project! You'll build a complete NOVA LAB station resource management system that uses:

  • Store with state - storing resource data (oxygen, water, energy)
  • Getters - calculating statuses, alerts, statistics
  • Actions - CRUD operations, resource consumption, alarms
  • Store composition - resource store + alert store
  • $subscribe - monitoring changes

Project Architecture

1stores/
2β”œβ”€β”€ resources.js     # Station resources (state, getters, actions)
3└── alerts.js        # Alert system (subscribe to resources)

Data Flow

1ResourceStore (state: oxygen, water, energy)
2  β”œβ”€β”€ getters β†’ statusLevel, criticalResources, summary
3  β”œβ”€β”€ actions β†’ consume(), refill(), emergencyProtocol()
4  └── $subscribe β†’ AlertStore.checkLevels()
5
6AlertStore (state: alerts)
7  β”œβ”€β”€ getters β†’ activeAlerts, criticalCount
8  └── actions β†’ addAlert(), dismissAlert()

Task

Analyze the working system in the editor. Pay attention to:

  1. ResourceStore - how state, getters, and actions work together
  2. AlertStore - how one store reacts to changes in another
  3. App Component - how storeToRefs and actions are used in the template
  4. $subscribe - automatic alert creation when resource levels are low

Key patterns:

  • Composition API style with ref() and computed()
  • Asynchronous actions with try/catch/finally
  • Store composition - resource store calls alert store
  • storeToRefs for reactive destructuring
  • $patch for batch state updates
Code for this lesson: App.vue
1<script setup>
2import { ref, computed, watch } from 'vue'
3
4// ===== ResourceStore =====
5const resources = ref([
6  { id: 'oxygen', name: 'Oxygen', level: 85, max: 100, unit: '%', consumeRate: 2 },
7  { id: 'water', name: 'Water', level: 1200, max: 1500, unit: 'L', consumeRate: 30 },
8  { id: 'energy', name: 'Energy', level: 72, max: 100, unit: '%', consumeRate: 5 }
9])
10
11// Resource Getters
12const totalResourceHealth = computed(() => {
13  const avg = resources.value.reduce((sum, r) => sum + (r.level / r.max * 100), 0)
14  return Math.round(avg / resources.value.length)
15})
16
17const criticalResources = computed(() =>
18  resources.value.filter(r => (r.level / r.max * 100) < 25)
19)
20
21const stationStatus = computed(() => {
22  if (criticalResources.value.length > 0) return 'CRITICAL'
23  if (totalResourceHealth.value < 50) return 'WARNING'
24  return 'NOMINAL'
25})
26
27// Resource Actions
28function consumeResource(id) {
29  const r = resources.value.find(res => res.id === id)
30  if (r) {
31    r.level = Math.max(0, r.level - r.consumeRate)
32  }
33}
34
35function refillResource(id) {
36  const r = resources.value.find(res => res.id === id)
37  if (r) r.level = r.max
38}
39
40function consumeAll() {
41  resources.value.forEach(r => {
42    r.level = Math.max(0, r.level - r.consumeRate)
43  })
44}
45
46function refillAll() {
47  resources.value.forEach(r => r.level = r.max)
48}
49
50// ===== AlertStore =====
51const alerts = ref([])
52
53const activeAlerts = computed(() => alerts.value.filter(a => !a.dismissed))
54const criticalAlertCount = computed(() => alerts.value.filter(a => a.type === 'critical' && !a.dismissed).length)
55
56function addAlert(message, type = 'info') {
57  alerts.value.push({
58    id: Date.now() + Math.random(),
59    message,
60    type,
61    time: new Date().toLocaleTimeString(),
62    dismissed: false
63  })
64}
65
66function dismissAlert(id) {
67  const alert = alerts.value.find(a => a.id === id)
68  if (alert) alert.dismissed = true
69}
70
71function clearAlerts() {
72  alerts.value = []
73}
74
75// ===== Cross-store: $subscribe simulation =====
76watch(resources, () => {
77  resources.value.forEach(r => {
78    const pct = (r.level / r.max) * 100
79    if (pct < 10) {
80      const exists = alerts.value.some(a =>
81        a.message.includes(r.name) && a.type === 'critical' && !a.dismissed
82      )
83      if (!exists) addAlert(`${r.name} EMERGENCY: ${r.level}${r.unit}!`, 'critical')
84    } else if (pct < 25) {
85      const exists = alerts.value.some(a =>
86        a.message.includes(r.name) && a.type === 'warning' && !a.dismissed
87      )
88      if (!exists) addAlert(`${r.name} low: ${r.level}${r.unit}`, 'warning')
89    }
90  })
91}, { deep: true })
92</script>
93
94<template>
95  <div class="nova-lab">
96    <h1>NOVA LAB - Resource Management System</h1>
97
98    <div class="status-bar" :class="stationStatus.toLowerCase()">
99      Station Status: {{ stationStatus }} | Health: {{ totalResourceHealth }}%
100      <span v-if="criticalAlertCount"> | Alerts: {{ criticalAlertCount }}</span>
101    </div>
102
103    <div class="main-grid">
104      <div class="resources-section">
105        <h2>Resources</h2>
106        <div v-for="r in resources" :key="r.id" class="resource-card"
107          :class="{ critical: (r.level/r.max*100) < 25, warning: (r.level/r.max*100) < 50 }">
108          <div class="r-header">
109            <strong>{{ r.name }}</strong>
110            <span>{{ r.level }}/{{ r.max }} {{ r.unit }}</span>
111          </div>
112          <div class="r-bar">
113            <div class="r-fill" :style="{ width: (r.level/r.max*100) + '%' }"></div>
114          </div>
115          <div class="r-btns">
116            <button @click="consumeResource(r.id)" class="btn-sm">
117              Consume (-{{ r.consumeRate }})
118            </button>
119            <button @click="refillResource(r.id)" class="btn-sm btn-green">Refill</button>
120          </div>
121        </div>
122        <div class="global-btns">
123          <button @click="consumeAll" class="btn">Consume All</button>
124          <button @click="refillAll" class="btn btn-green">Refill All</button>
125        </div>
126      </div>
127
128      <div class="alerts-section">
129        <div class="alerts-header">
130          <h2>Alert System</h2>
131          <button v-if="alerts.length" @click="clearAlerts" class="btn-sm">Clear</button>
132        </div>
133        <div v-for="a in activeAlerts" :key="a.id" class="alert-item" :class="a.type">
134          <div class="alert-content">
135            <span class="alert-time">{{ a.time }}</span>
136            <span>{{ a.message }}</span>
137          </div>
138          <button @click="dismissAlert(a.id)" class="dismiss">x</button>
139        </div>
140        <p v-if="!activeAlerts.length" class="empty">All systems nominal</p>
141      </div>
142    </div>
143  </div>
144</template>
145
146<style scoped>
147.nova-lab {
148  background: #0a0e27;
149  color: #00ff88;
150  min-height: 100vh;
151  padding: 20px;
152  font-family: monospace;
153}
154h1 { color: #00ff88; margin-bottom: 15px; text-align: center; }
155h2 { color: #00b4d8; margin-bottom: 10px; }
156.status-bar {
157  text-align: center;
158  padding: 10px;
159  border: 2px solid;
160  margin-bottom: 15px;
161  font-weight: bold;
162}
163.status-bar.nominal { border-color: #00ff88; color: #00ff88; }
164.status-bar.warning { border-color: #ff9900; color: #ff9900; }
165.status-bar.critical { border-color: #ff4444; color: #ff4444; background: rgba(255,68,68,0.1); }
166.main-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
167.resources-section, .alerts-section {
168  background: rgba(0, 180, 216, 0.1);
169  border: 2px solid #00b4d8;
170  padding: 15px;
171}
172.resource-card {
173  border: 2px solid #00ff88;
174  padding: 10px;
175  margin-bottom: 8px;
176}
177.resource-card.warning { border-color: #ff9900; }
178.resource-card.critical { border-color: #ff4444; background: rgba(255,68,68,0.05); }
179.r-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
180.r-bar { height: 10px; background: #1a1e3f; border: 1px solid #00b4d8; }
181.r-fill { height: 100%; background: #00ff88; transition: width 0.3s; }
182.resource-card.warning .r-fill { background: #ff9900; }
183.resource-card.critical .r-fill { background: #ff4444; }
184.r-btns { display: flex; gap: 5px; margin-top: 6px; }
185.btn-sm {
186  background: #00b4d8;
187  color: #0a0e27;
188  border: none;
189  padding: 3px 10px;
190  cursor: pointer;
191  font-family: monospace;
192  font-size: 0.85em;
193}
194.btn-green { background: #00ff88; }
195.global-btns { display: flex; gap: 8px; margin-top: 10px; }
196.btn {
197  background: #00b4d8;
198  color: #0a0e27;
199  border: none;
200  padding: 6px 14px;
201  cursor: pointer;
202  font-family: monospace;
203  flex: 1;
204}
205.btn:hover { background: #00ff88; }
206.btn.btn-green { background: #00ff88; }
207.alerts-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
208.alert-item {
209  display: flex;
210  justify-content: space-between;
211  align-items: center;
212  padding: 8px;
213  margin: 4px 0;
214  border: 1px solid;
215}
216.alert-item.info { border-color: #00b4d8; }
217.alert-item.warning { border-color: #ff9900; color: #ff9900; }
218.alert-item.critical { border-color: #ff4444; color: #ff4444; }
219.alert-time { color: #00b4d8; margin-right: 8px; font-size: 0.85em; }
220.dismiss {
221  background: none;
222  border: 1px solid currentColor;
223  color: inherit;
224  cursor: pointer;
225  padding: 1px 5px;
226  font-family: monospace;
227}
228.empty { color: #666; font-style: italic; }
229</style>

Spotted a mistake in this lesson?

Hands-on tasks in the game

  • Code editor

    Build a complete resource management system for the NOVA LAB station

Useful articles